From 66fc04045d5a453f27311e40a687b677a6f1b516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20S=C4=99k?= Date: Tue, 11 Aug 2026 12:42:38 +0200 Subject: [PATCH 1/4] feat: android slopification --- CLAUDE.md | 1 + .../audiodocs/docs/inputs/audio-recorder.mdx | 42 ++++++ .../audiodocs/docs/other/audio-api-plugin.mdx | 13 ++ .../system/CentralizedForegroundService.kt | 120 ++++++++++++++++-- .../system/ForegroundServiceManager.kt | 9 ++ .../src/plugin/withAudioAPI.ts | 13 +- 6 files changed, 186 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85fd9c394..3ccbb8b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ packages/custom-node-generator/ # Code generation tooling - **New Architecture Ready**: Supports both old Bridge and new TurboModules/Fabric - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets +- **Notification-Driven Foreground Service (Android)**: `NotificationRegistry.showNotification` → `ForegroundServiceManager.subscribe` → `CentralizedForegroundService`; service lifetime follows notification visibility, never recorder/player state. The library manifest is empty — consuming apps declare the `` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal ### Native Module Entry Points - iOS: `ios/audioapi/ios/AudioAPIModule.mm` diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index c82fec84a..5609be69c 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -93,6 +93,48 @@ Additionally to be able to record audio while application is in the background, +### Keeping the recording alive when the app is closed + +By default the foreground service stops when the user swipes the app away from the recents screen (`android:stopWithTask="true"`), which kills the app process and ends any in-progress recording. You can opt into letting the service — and therefore the process, the JS runtime, and the active recording — survive task removal: + + + + Set the `androidFSStopWithTask` option of the [expo plugin](/docs/other/audio-api-plugin#androidfsstopwithtask) to `false`: + + ```json + { + "plugins": [ + [ + "react-native-audio-api", + { + "androidFSStopWithTask": false + } + ] + ] + } + ``` + + + + In a bare react-native application, set `android:stopWithTask="false"` on the service entry in your `AndroidManifest.xml`: + + ```xml + + ``` + + + + +For the recording to actually survive, all of the following must hold: + +- The foreground service only exists while a library notification is shown. Call [`RecordingNotificationManager.show()`](/docs/system/recording-notification-manager#show) while the app is still in the foreground — before the user leaves the app — otherwise there is no service to keep alive. +- `androidFSTypes` must include `"microphone"` (manifest `foregroundServiceType="microphone"`), and on Android 14+ (API 34) the app needs the `android.permission.FOREGROUND_SERVICE_MICROPHONE` permission. +- Android's while-in-use rule applies: microphone access must begin while the app is in the foreground. Starting a recording from the background is not possible. + +:::caution +Even with `stopWithTask="false"`, the system can still kill the process (memory pressure, OEM battery managers). Recording cannot self-restart from the background — the user has to reopen the app. To limit data loss in that case, tune the file-output options [`androidFlushIntervalMs`](/docs/inputs/audio-recorder#androidflushintervalms) and [`rotateIntervalBytes`](/docs/inputs/audio-recorder#audiorecorderfileoptions). +::: + ## Examples diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index ecf3dae12..c32bbc5ca 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -18,6 +18,7 @@ interface Options { androidPermissions: string[]; androidForegroundService: boolean; androidFSTypes: string[]; + androidFSStopWithTask?: boolean; } ``` @@ -133,3 +134,15 @@ Types description: Runtime prerequisites: - Request and be granted the RECORD_AUDIO runtime permission. + +### `androidFSStopWithTask` + +Defaults to `true`. + +Controls the `android:stopWithTask` attribute of the Foreground Service injected by the plugin. With the default value (`true`), the service stops when the user swipes the app away from the recents screen. + +Set it to `false` to emit `android:stopWithTask="false"` on the service entry — on task removal the service keeps running, which keeps the app process (and e.g. an in-progress recording) alive. + +:::info +The Foreground Service only exists while a library notification is shown, so this option has an effect only if a notification (e.g. via `RecordingNotificationManager.show()`) is displayed before the user closes the app. See [keeping the recording alive when the app is closed](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. +::: diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt index 086e348a3..059c75351 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt @@ -4,11 +4,15 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.util.Log +import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import com.swmansion.audioapi.system.MediaSessionManager.CHANNEL_ID import com.swmansion.audioapi.system.notification.NotificationRegistry @@ -23,6 +27,9 @@ class CentralizedForegroundService : Service() { private const val TAG = "CentralizedForegroundService" const val ACTION_START = "START_FOREGROUND" const val ACTION_STOP = "STOP_FOREGROUND" + + private const val PLACEHOLDER_CHANNEL_ID = "audio_service_placeholder" + private const val PLACEHOLDER_NOTIFICATION_ID = 300 } override fun onBind(intent: Intent?): IBinder? = null @@ -45,6 +52,13 @@ class CentralizedForegroundService : Service() { return START_NOT_STICKY } + override fun onTaskRemoved(rootIntent: Intent?) { + // Fires only when the app opted into android:stopWithTask="false" — the service (and any + // in-progress recording or playback) intentionally outlives the removed task. + Log.i(TAG, "App task removed, foreground service keeps running") + super.onTaskRemoved(rootIntent) + } + private fun startForegroundWithNotification() { try { createNotificationChannelIfNeeded() @@ -52,20 +66,15 @@ class CentralizedForegroundService : Service() { // Get the first available notification val existingNotification = findExistingNotification() if (existingNotification == null) { - Log.w(TAG, "No notification available to start foreground service") + // The service was started with Context.startForegroundService(), so startForeground() + // must still be called — skipping it crashes with ForegroundServiceDidNotStartInTimeException. + Log.w(TAG, "No notification available, starting foreground with a placeholder and stopping") + startForegroundWithPlaceholderAndStop() return } val (notificationId, notification) = existingNotification - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - notificationId, - notification, - ) - } else { - startForeground(notificationId, notification) - } + startForegroundCompat(notificationId, notification) Log.d(TAG, "Centralized foreground service started with notification ID: $notificationId") } catch (e: Exception) { @@ -73,6 +82,77 @@ class CentralizedForegroundService : Service() { } } + private fun startForegroundWithPlaceholderAndStop() { + createPlaceholderNotificationChannelIfNeeded() + + val placeholderNotification = + NotificationCompat + .Builder(this, PLACEHOLDER_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("Audio service") + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + startForegroundCompat(PLACEHOLDER_NOTIFICATION_ID, placeholderNotification) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun startForegroundCompat( + notificationId: Int, + notification: Notification, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + startForeground(notificationId, notification) + return + } + + // Passing a type the app did not declare in its manifest throws, so only the intersection + // of desired and declared types may be used. + val serviceTypes = activeNotificationServiceTypes() and manifestDeclaredServiceTypes() + when { + serviceTypes != 0 -> { + startForeground(notificationId, notification, serviceTypes) + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> { + startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) + } + + else -> { + startForeground(notificationId, notification) + } + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun activeNotificationServiceTypes(): Int { + var serviceTypes = 0 + + if (NotificationRegistry.getBuiltNotification(PlaybackNotification.ID) != null) { + serviceTypes = serviceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + NotificationRegistry.getBuiltNotification(RecordingNotification.ID) != null + ) { + serviceTypes = serviceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + + return serviceTypes + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun manifestDeclaredServiceTypes(): Int = + try { + packageManager + .getServiceInfo(ComponentName(this, CentralizedForegroundService::class.java), PackageManager.GET_META_DATA) + .foregroundServiceType + } catch (e: PackageManager.NameNotFoundException) { + Log.w(TAG, "Unable to read foreground service types declared in the manifest: ${e.message}") + 0 + } + private fun findExistingNotification(): Pair? { // Check for playback notification first (priority) NotificationRegistry.getBuiltNotification(PlaybackNotification.ID)?.let { @@ -106,8 +186,28 @@ class CentralizedForegroundService : Service() { } } + private fun createPlaceholderNotificationChannelIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (notificationManager.getNotificationChannel(PLACEHOLDER_CHANNEL_ID) == null) { + val channel = + NotificationChannel( + PLACEHOLDER_CHANNEL_ID, + "Audio Service Placeholder", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Short-lived notification shown while the audio service shuts down" + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + } + } + override fun onDestroy() { Log.d(TAG, "Centralized foreground service destroyed") + ForegroundServiceManager.onServiceDestroyed() super.onDestroy() } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt index 4abd0693b..93ff45c2b 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt @@ -56,6 +56,15 @@ object ForegroundServiceManager { */ fun isServiceRunning(): Boolean = isServiceRunning + /** + * Called from [CentralizedForegroundService.onDestroy] so a later [subscribe] can start + * the service again after the system destroys it. + */ + @Synchronized + internal fun onServiceDestroyed() { + isServiceRunning = false + } + private fun startServiceIfNeeded() { if (!isServiceRunning && subscribers.isNotEmpty()) { startForegroundService() diff --git a/packages/react-native-audio-api/src/plugin/withAudioAPI.ts b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts index d11e4052d..c4c4b5aa1 100644 --- a/packages/react-native-audio-api/src/plugin/withAudioAPI.ts +++ b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts @@ -15,6 +15,13 @@ interface Options { androidPermissions: string[]; androidForegroundService: boolean; androidFSTypes: string[]; + /** + * Controls `android:stopWithTask` on the injected foreground service. When + * false, swiping the app away from recents keeps the service — and therefore + * the app process and any in-progress recording — running (Android calls + * onTaskRemoved instead of stopping the service). Defaults to true. + */ + androidFSStopWithTask?: boolean; disableFFmpeg: boolean; disableStaticExternalLibs: boolean; } @@ -28,6 +35,7 @@ const withDefaultOptions = (options: Partial): Options => { ], androidForegroundService: true, androidFSTypes: ['mediaPlayback'], + androidFSStopWithTask: true, disableFFmpeg: false, disableStaticExternalLibs: false, ...options, @@ -65,7 +73,7 @@ const withAndroidPermissions: ConfigPlugin = ( const withForegroundService: ConfigPlugin = ( config, - { androidFSTypes }: Options + { androidFSTypes, androidFSStopWithTask }: Options ) => { return withAndroidManifest(config, (mod) => { const manifest = mod.modResults; @@ -78,7 +86,8 @@ const withForegroundService: ConfigPlugin = ( $: { 'android:name': 'com.swmansion.audioapi.system.CentralizedForegroundService', - 'android:stopWithTask': 'true', + 'android:stopWithTask': + androidFSStopWithTask === false ? 'false' : 'true', 'android:foregroundServiceType': SFTypes, }, intentFilter: [], From a42c54b129c1e102d3f85b9142fab041bda72d91 Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 17 Aug 2026 13:56:59 +0200 Subject: [PATCH 2/4] feat: native recording notification controls independent of js runtime The recording notification's pause, resume and stop actions now act on the recorder natively, so they keep working after the app task is removed while the foreground service (stopWithTask=false) keeps the recording alive: - ActiveRecorderHandle: process-global one-slot handle to the live recorder (registered by AudioRecorderHostObject), with a consume-once stash of the file info produced by a native stop - NativeRecorderControl: static-JNI entry points callable from Kotlin without a React context; the notification receiver stops/pauses/resumes through it on an executor and still emits the matching AudioEvent so a live app can sync its UI (new event: RECORDING_NOTIFICATION_STOP) - RecordingNotification rewritten to standard NotificationCompat actions (RemoteViews layouts removed), rebuilt on every show(); adds stop action, action titles, deepLinkUri tap routing (ACTION_VIEW) and a chronometer that excludes paused spans; native pause/resume re-post the notification so the action button flips without JS - onErrorAfterClose now restores the pre-teardown state after a stream reclaim instead of force-resuming a paused recording Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/post-work-checks/SKILL.md | 2 + .claude/skills/thread-safety-itc/SKILL.md | 1 + CLAUDE.md | 1 + .../android/app/src/main/AndroidManifest.xml | 2 +- .../system/recording-notification-manager.mdx | 54 ++- .../src/main/cpp/audioapi/android/OnLoad.cpp | 6 +- .../android/core/AndroidAudioRecorder.cpp | 13 +- .../android/system/NativeRecorderControl.cpp | 32 ++ .../android/system/NativeRecorderControl.hpp | 24 + .../swmansion/audioapi/system/AudioEvent.kt | 1 + .../audioapi/system/MediaSessionManager.kt | 25 + .../audioapi/system/NativeRecorderControl.kt | 30 ++ .../notification/NotificationRegistry.kt | 33 ++ .../notification/RecordingNotification.kt | 439 +++++++++--------- .../RecordingNotificationReceiver.kt | 83 +++- .../state/RecordingNotificationState.kt | 24 +- .../src/main/res/layout/btn_round_ripple.xml | 9 - .../res/layout/notification_collapsed.xml | 45 -- .../main/res/layout/notification_expanded.xml | 44 -- .../inputs/AudioRecorderHostObject.cpp | 6 + .../inputs/AudioRecorderHostObject.h | 1 + .../HostObjects/utils/JsEnumParser.cpp | 2 + .../core/inputs/ActiveRecorderHandle.cpp | 98 ++++ .../core/inputs/ActiveRecorderHandle.h | 65 +++ .../core/utils/AudioRecorderCallback.cpp | 4 - .../core/utils/AudioRecorderCallback.h | 6 +- .../common/cpp/audioapi/events/AudioEvent.h | 1 + .../core/inputs/ActiveRecorderHandleTest.cpp | 192 ++++++++ .../src/system/notification/types.ts | 36 ++ 29 files changed, 933 insertions(+), 346 deletions(-) create mode 100644 packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp create mode 100644 packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt delete mode 100644 packages/react-native-audio-api/android/src/main/res/layout/btn_round_ripple.xml delete mode 100644 packages/react-native-audio-api/android/src/main/res/layout/notification_collapsed.xml delete mode 100644 packages/react-native-audio-api/android/src/main/res/layout/notification_expanded.xml create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp diff --git a/.claude/skills/post-work-checks/SKILL.md b/.claude/skills/post-work-checks/SKILL.md index 951f994fc..3c038935f 100644 --- a/.claude/skills/post-work-checks/SKILL.md +++ b/.claude/skills/post-work-checks/SKILL.md @@ -117,6 +117,8 @@ yarn workspace react-native-audio-api run test:cpp yarn test # from monorepo root — runs test:js + test:cpp ``` +**Gotcha**: jest resolves `react-native-audio-api/mock` through `mock/package.json` → the built `lib/` output, not `src/`. After editing `src/mock/` (or any API the tests import), run `yarn build` in the package first, or tests exercise the stale build ("X is not a function" for newly added members). + **When**: after any change to C++ files or TypeScript files in `src/`. Prefer this for a quick local test loop covering both TS and C++ logic; run `yarn validate:fast` before opening a PR. ### AudioEvent enum sync check diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 86d1c4bd6..ba3050ccf 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -130,6 +130,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de | Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) | | CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread | | Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` | +| Platform code (Kotlin) must reach a C++ object with no JS runtime alive | Process-global handle (`ActiveRecorderHandle` — mutex + `weak_ptr`, registered by the HostObject ctor/dtor) + static-JNI `JavaClass` (`NativeRecorderControl`, no HybridData needed). Blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` — Kotlin threads are already JNI-attached | --- diff --git a/CLAUDE.md b/CLAUDE.md index 4059d85ae..f1715d44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ packages/custom-node-generator/ # Code generation tooling - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets - **Notification-Driven Foreground Service (Android)**: `NotificationRegistry.showNotification` → `ForegroundServiceManager.subscribe` → `CentralizedForegroundService`; service lifetime follows notification visibility, never recorder/player state. The library manifest is empty — consuming apps declare the `` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal +- **JS-Independent Recorder Control (Android)**: the recording notification's stop action must work after task removal, when no JS listener is reachable. `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` registered by `AudioRecorderHostObject`) exposes the live recorder process-globally; Kotlin reaches it through the static-JNI `NativeRecorderControl` object (no HybridData/React context needed — the reverse of the `NativeFileInfo` pattern). Results of a native stop are stashed consume-once in the handle - **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`). ### Native Module Entry Points diff --git a/apps/fabric-example/android/app/src/main/AndroidManifest.xml b/apps/fabric-example/android/app/src/main/AndroidManifest.xml index 640f1c0b2..091781dca 100644 --- a/apps/fabric-example/android/app/src/main/AndroidManifest.xml +++ b/apps/fabric-example/android/app/src/main/AndroidManifest.xml @@ -18,7 +18,7 @@ android:theme="@style/AppTheme" android:usesCleartextTraffic="${usesCleartextTraffic}" android:supportsRtl="true"> - + The `RecordingNotificationManager` provides system integration with [`AudioRecorder`](/docs/inputs/audio-recorder) on Android. -It can send events about pausing and resuming to your application. +It shows a standard notification with pause/resume and (optionally) stop actions, can route notification taps to a specific screen, and sends action events to your application. :::note iOS `RecordingNotificationManager` is not available on iOS. For a recording indicator on the Lock Screen and in the Dynamic Island, use a [Live Activity](https://docs.expo.dev/versions/latest/sdk/widgets/) built with [`expo-widgets`](https://docs.expo.dev/versions/latest/sdk/widgets/). @@ -23,9 +23,10 @@ RecordingNotificationManager.show({ contentText: 'Recording...', paused: false, smallIconResourceName: 'icon_to_display', - pauseIconResourceName: 'pause_icon', - resumeIconResourceName: 'resume_icon', color: 0xff6200, + showStopAction: true, + deepLinkUri: 'myapp://record', + usesChronometer: true, }); const pauseEventListener = RecordingNotificationManager.addEventListener('recordingNotificationPause', () => { @@ -34,19 +35,45 @@ const pauseEventListener = RecordingNotificationManager.addEventListener('record const resumeEventListener = RecordingNotificationManager.addEventListener('recordingNotificationResume', () => { console.log('Notification resume action received'); }); +const stopEventListener = RecordingNotificationManager.addEventListener('recordingNotificationStop', () => { + console.log('Notification stop action received'); +}); pauseEventListener.remove(); resumeEventListener.remove(); +stopEventListener.remove(); RecordingNotificationManager.hide(); ``` +## Native action handling + +All notification actions act on the recorder **natively**, without a JS round-trip. This matters when the recording outlives the app UI (see [keeping the recording alive when the app is closed](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed)) — pause, resume and stop keep working even after the app task has been removed and no JS listener is reachable. + +- **Pause / resume** pause or resume the recorder and flip the notification's action button. The matching event (`recordingNotificationPause` / `recordingNotificationResume`) still fires so a live app can sync its UI — handlers calling `AudioRecorder.pause()` / `resume()` again are harmless, the recorder ignores same-state transitions. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.takeLastRecordingResult()`](/docs/inputs/audio-recorder#takelastrecordingresult)), emits `recordingNotificationStop`, then hides the notification, which also stops the foreground service. Unlike pause/resume, your `recordingNotificationStop` listener should **not** call `AudioRecorder.stop()` — the recording is already stopped. Collect the files with `AudioRecorder.takeLastRecordingResult()` instead. + +## Routing the notification tap + +By default, tapping the notification opens the app's launcher activity. Set `deepLinkUri` to attach a URI to the tap intent; React Native delivers it through [`Linking`](https://reactnative.dev/docs/linking) (`getInitialURL()` on cold start, the `url` event otherwise). With React Navigation, map it to a screen with a [`linking` config](https://reactnavigation.org/docs/deep-linking/): + +```tsx +const linking = { + prefixes: ['myapp://'], + config: { screens: { RecordScreen: 'record' } }, +}; + + +``` + +No `AndroidManifest.xml` changes are needed — the notification uses an explicit launch intent, so the URI does not go through intent filters. (If you also want the same URI to work from a browser or `adb`, declare your own scheme intent filter, e.g. via Expo's `scheme` option.) + ## Methods ### `show` Shows the recording notification with the parameters. -Metadata is saved between calls, so after the initial pass to the show method, you need only call it with elements that are supposed to change. +Metadata is saved between calls, so after the initial pass to the show method, you need only call it with elements that are supposed to change. The only exception is `paused`, which resets to `false` when absent. | Parameter |Type| Description| | :---: | :---: | :---- | @@ -60,8 +87,7 @@ Resource name is a path to resource placed in res/drawable folder. It has to be ::: :::caution -If nothing is displayed, even though your name is correct, try decreasing size of your resource. -Notification can look vastly different on different android devices. +The notification uses the standard Android template, so its exact look varies between devices and Android versions. On Android 12+ the system renders actions as text buttons — the `pauseIconResourceName`, `resumeIconResourceName` and `stopIconResourceName` icons only show up on older versions; use the `*ActionTitle` options to control the visible labels. ::: ### `hide` @@ -97,12 +123,19 @@ Adds an event listener for notification actions. interface RecordingNotificationInfo { title?: string; contentText?: string; - paused?: boolean; // flag indicating whether to display pauseIcon or resumeIcon + paused?: boolean; // flag indicating whether to display the pause or the resume action smallIconResourceName?: string; largeIconResourceName?: string; - pauseIconResourceName?: string; - resumeIconResourceName?: string; - color?: number; // + pauseIconResourceName?: string; // ignored on Android 12+ + resumeIconResourceName?: string; // ignored on Android 12+ + color?: number; + showStopAction?: boolean; // shows the native stop action, default: false + stopIconResourceName?: string; // ignored on Android 12+ + pauseActionTitle?: string; // default: 'Pause' + resumeActionTitle?: string; // default: 'Resume' + stopActionTitle?: string; // default: 'Stop' + deepLinkUri?: string; // URI attached to the notification tap intent + usesChronometer?: boolean; // shows the elapsed recording time, default: false } ``` @@ -117,6 +150,7 @@ interface EventEmptyType {} interface RecordingNotificationEvent { recordingNotificationPause: EventEmptyType; recordingNotificationResume: EventEmptyType; + recordingNotificationStop: EventEmptyType; } type RecordingNotificationEventName = keyof RecordingNotificationEvent; diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/OnLoad.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/OnLoad.cpp index 401972a97..e94fa110e 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/OnLoad.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/OnLoad.cpp @@ -1,9 +1,13 @@ #include +#include #include using namespace audioapi; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { - return facebook::jni::initialize(vm, [] { AudioAPIModule::registerNatives(); }); + return facebook::jni::initialize(vm, [] { + AudioAPIModule::registerNatives(); + NativeRecorderControl::registerNatives(); + }); } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp index 6adcc308a..5acf0fbd2 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp @@ -556,11 +556,15 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re return; } + const auto stateBeforeTeardown = state_.load(std::memory_order_acquire); + cleanup(); auto streamResult = openAudioStream(); if (!streamResult.is_ok()) { + // Deliberately left Idle (by cleanup()): restoring Paused here would let a later + // resume() start a stream that no longer exists. uint64_t callbackId = errorCallbackId_.load(std::memory_order_acquire); if (audioEventHandlerRegistry_ == nullptr || callbackId == 0) { @@ -575,8 +579,13 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re return; } - mStream_->requestStart(); - state_.store(RecorderState::Recording, std::memory_order_release); + // Restore the interrupted session's state instead of unconditionally recording — + // a paused session must stay paused, or the reopened stream would silently turn + // the microphone back on against an explicit user action. + if (stateBeforeTeardown == RecorderState::Recording) { + mStream_->requestStart(); + } + state_.store(stateBeforeTeardown, std::memory_order_release); } } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp new file mode 100644 index 000000000..7a6b25012 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp @@ -0,0 +1,32 @@ +#include + +#include + +namespace audioapi { + +void NativeRecorderControl::registerNatives() { + javaClassStatic()->registerNatives({ + makeNativeMethod("stopActiveRecording", NativeRecorderControl::stopActiveRecording), + makeNativeMethod("pauseActiveRecording", NativeRecorderControl::pauseActiveRecording), + makeNativeMethod("resumeActiveRecording", NativeRecorderControl::resumeActiveRecording), + makeNativeMethod("isRecordingActive", NativeRecorderControl::isRecordingActive), + }); +} + +jboolean NativeRecorderControl::stopActiveRecording(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().stopActiveRecording()); +} + +jboolean NativeRecorderControl::pauseActiveRecording(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().pauseActiveRecording()); +} + +jboolean NativeRecorderControl::resumeActiveRecording(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().resumeActiveRecording()); +} + +jboolean NativeRecorderControl::isRecordingActive(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().isRecordingOngoing()); +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp new file mode 100644 index 000000000..92a378dd9 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace audioapi { + +using namespace facebook; + +/// @brief JNI statics that let Kotlin reach the active recorder without a React +/// context or JS runtime, e.g. from the recording-notification stop action after +/// the app task was removed. Backed by ActiveRecorderHandle. +class NativeRecorderControl : public jni::JavaClass { + public: + static auto constexpr kJavaDescriptor = "Lcom/swmansion/audioapi/system/NativeRecorderControl;"; + + static void registerNatives(); + + static jboolean stopActiveRecording(jni::alias_ref); + static jboolean pauseActiveRecording(jni::alias_ref); + static jboolean resumeActiveRecording(jni::alias_ref); + static jboolean isRecordingActive(jni::alias_ref); +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt index 72e02d5f0..8ac9cdad2 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt @@ -25,4 +25,5 @@ enum class AudioEvent { POSITION_CHANGED, BUFFER_ENDED, RECORDER_ERROR, + RECORDING_NOTIFICATION_STOP, } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt index af739a8d0..a3c907088 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt @@ -24,6 +24,7 @@ import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDI import com.swmansion.audioapi.system.notification.NotificationRegistry import com.swmansion.audioapi.system.notification.PlaybackNotification import com.swmansion.audioapi.system.notification.PlaybackNotificationReceiver +import com.swmansion.audioapi.system.notification.RecordingNotification import java.lang.ref.WeakReference object MediaSessionManager { @@ -262,5 +263,29 @@ object MediaSessionManager { notificationRegistry.hideNotification(key) } + /** + * Hides the recording notification without knowing its JS-chosen key. Used by the + * notification stop action, which also unwinds the foreground service through the + * registry's unsubscribe path. + */ + fun hideRecordingNotification() { + if (!::notificationRegistry.isInitialized) { + return + } + notificationRegistry.hideNotificationByNotificationId(RecordingNotification.ID) + } + + /** + * Flips the recording notification between its pause and resume looks. Used by + * native-initiated pause/resume, which can't go through [showNotification] — there + * is no JS to supply options. + */ + fun setRecordingNotificationPaused(paused: Boolean) { + if (!::notificationRegistry.isInitialized) { + return + } + notificationRegistry.updateRecordingNotificationPausedState(paused) + } + fun isNotificationActive(key: String): Boolean = notificationRegistry.isNotificationActive(key) } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt new file mode 100644 index 000000000..a54727a54 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt @@ -0,0 +1,30 @@ +package com.swmansion.audioapi.system + +/** + * Direct access to the active C++ recorder, independent of the React context and the JS + * runtime. This is what allows the recording-notification stop action to end a recording + * after the app task has been removed. + */ +object NativeRecorderControl { + init { + System.loadLibrary("react-native-audio-api") + } + + /** + * Stops the active recording and finalizes its output file. Blocking — never call on + * the main thread. The file info is stashed natively for + * `AudioRecorder.takeLastRecordingResult()` on the JS side. + * + * @return true if a recording was stopped by this call. + */ + external fun stopActiveRecording(): Boolean + + /** Pauses an actively recording session. @return true if this call paused it. */ + external fun pauseActiveRecording(): Boolean + + /** Resumes a paused session. @return true if this call resumed it. */ + external fun resumeActiveRecording(): Boolean + + /** Non-blocking check whether a recording session (recording or paused) is active. */ + external fun isRecordingActive(): Boolean +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt index 0ab29555d..c94870ec0 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -1,5 +1,6 @@ package com.swmansion.audioapi.system.notification +import android.annotation.SuppressLint import android.app.Notification import android.util.Log import androidx.annotation.RequiresPermission @@ -105,6 +106,38 @@ class NotificationRegistry( } } + /** + * Hide a notification by its Android notification ID. + * Used by native-initiated flows (e.g. the recording stop action) that don't know + * the JS-chosen key. + * + * @param id The Android notification ID, e.g. [RecordingNotification.ID] + */ + fun hideNotificationByNotificationId(id: Int) { + notifications.entries + .firstOrNull { it.value.getNotificationId() == id } + ?.let { hideNotification(it.key) } + } + + /** + * Rebuild and re-post the recording notification with a new paused state. + * Used by native-initiated pause/resume so the action button flips even when JS + * is unreachable. No-op unless the recording notification is currently visible — + * which also means the POST_NOTIFICATIONS permission was already granted. + */ + @SuppressLint("MissingPermission") + fun updateRecordingNotificationPausedState(paused: Boolean) { + val entry = + notifications.entries.firstOrNull { + it.value.getNotificationId() == RecordingNotification.ID + } ?: return + if (!activeNotifications.getOrDefault(entry.key, false)) { + return + } + val recordingNotification = entry.value as? RecordingNotification ?: return + displayNotification(RecordingNotification.ID, recordingNotification.rebuildWithPausedState(paused)) + } + /** * Create a notification instance. * diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index 38924a24a..cdecf42b2 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -4,17 +4,13 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.content.ComponentCallbacks import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.res.Configuration -import android.graphics.Color import android.graphics.drawable.Icon +import android.net.Uri import android.os.Build import android.util.Log -import android.widget.RemoteViews -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.facebook.react.bridge.ReactApplicationContext @@ -29,213 +25,203 @@ class RecordingNotification( private val audioAPIModule: WeakReference, private val notificationId: Int, private val channelId: String, -) : BaseNotification, - ComponentCallbacks { +) : BaseNotification { companion object { private const val TAG = "RecordingNotification" const val ID = 200 + + private const val REQUEST_CODE_CONTENT = 2000 + private const val REQUEST_CODE_PAUSE = 2001 + private const val REQUEST_CODE_RESUME = 2002 + private const val REQUEST_CODE_STOP = 2003 } - private var state: RecordingNotificationState = - RecordingNotificationState( - darkTheme = - reactContext - .get()!! - .resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES, - initialized = false, - ) + private val state = RecordingNotificationState() private fun initializeNotification() { val context = reactContext.get() ?: throw IllegalStateException("React context is null") - if (!state.initialized) { - context.registerComponentCallbacks(this) - createNotificationChannel(context) - state.receiver = - RecordingNotificationReceiver(audioAPIModule.get()!!) - val filter = - IntentFilter().apply { - addAction(RecordingNotificationReceiver.NOTIFICATION_RECORDING_STOPPED) - addAction(RecordingNotificationReceiver.NOTIFICATION_RECORDING_RESUMED) - } - ContextCompat.registerReceiver( - context, - state.receiver, - filter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - - state.pauseIntent = - Intent(RecordingNotificationReceiver.NOTIFICATION_RECORDING_STOPPED).apply { - `package` = context.packageName - } - - state.resumeIntent = - Intent(RecordingNotificationReceiver.NOTIFICATION_RECORDING_RESUMED).apply { - `package` = context.packageName - } - state.darkTheme = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - state.initialized = true + if (state.initialized) { + return } + + createNotificationChannel(context) + state.receiver = RecordingNotificationReceiver(audioAPIModule.get()!!) + val filter = + IntentFilter().apply { + addAction(RecordingNotificationReceiver.ACTION_PAUSE) + addAction(RecordingNotificationReceiver.ACTION_RESUME) + addAction(RecordingNotificationReceiver.ACTION_STOP) + } + ContextCompat.registerReceiver( + context, + state.receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + state.initialized = true } override fun show(options: ReadableMap?): Notification { initializeNotification() val context = reactContext.get() ?: throw IllegalStateException("React context is null") - if (options != state.cachedRNOptions) { - state.cachedRNOptions = options - parseMapFromRN(options) - } - val builder = getBuilder() + parseMapFromRN(options) + return buildNotification(context) + } - if (state.smallIconResourceName != null) { - builder.setSmallIcon(context.resources.getIdentifier(state.smallIconResourceName, "drawable", context.packageName)) - } + /** + * Rebuilds with an updated paused flag, leaving the sticky RN options untouched. + * Used by native-initiated pause/resume so the action button flips even when JS + * is unreachable. + */ + fun rebuildWithPausedState(paused: Boolean): Notification { + val context = reactContext.get() ?: throw IllegalStateException("React context is null") + state.paused = paused + return buildNotification(context) + } - if (state.largeIconResourceName != null) { - val icon = - Icon.createWithResource( - context, - context.resources.getIdentifier(state.largeIconResourceName, "drawable", context.packageName), + private fun buildNotification(context: ReactApplicationContext): Notification { + // The notification is rebuilt from scratch on every show() so that every option — + // including the tap intent — reflects the latest values. + val builder = + NotificationCompat + .Builder(context, channelId) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(state.title) + .setContentText(state.contentText) + .setSmallIcon( + resolveDrawable(context, state.smallIconResourceName) ?: android.R.drawable.ic_btn_speak_now, ) - builder.setLargeIcon(icon) - } - if (state.backgroundColor != null) { - builder.setColor(state.backgroundColor!!) + resolveDrawable(context, state.largeIconResourceName)?.let { + builder.setLargeIcon(Icon.createWithResource(context, it)) } + state.backgroundColor?.let { builder.setColor(it) } - val collapsedView = RemoteViews(context.packageName, R.layout.notification_collapsed) - val expandedView = RemoteViews(context.packageName, R.layout.notification_expanded) - - val (pauseResumePendingIntent, iconId) = setupPauseResumeIntent(context) + setupContentIntent(context, builder) + setupActions(context, builder) + setupChronometer(builder) - setupRemoteView(listOf(collapsedView, expandedView), pauseResumePendingIntent, iconId) + return builder.build() + } - builder - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) - .setCustomContentView(collapsedView) - .setCustomBigContentView(expandedView) - .setContentTitle(state.title) - .setContentText(state.contentText) + private fun setupContentIntent( + context: Context, + builder: NotificationCompat.Builder, + ) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return + state.deepLinkUri?.let { + // React Native's Linking only surfaces intent data for ACTION_VIEW — with the + // launcher's ACTION_MAIN the URI would be silently ignored. The intent stays + // explicit (component set), so no intent filter is consulted. + launchIntent.action = Intent.ACTION_VIEW + launchIntent.removeCategory(Intent.CATEGORY_LAUNCHER) + launchIntent.data = Uri.parse(it) + } + builder.setContentIntent( + PendingIntent.getActivity( + context, + REQUEST_CODE_CONTENT, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + } - if (state.backgroundColor != null) { - builder.setColor(state.backgroundColor!!) + private fun setupActions( + context: Context, + builder: NotificationCompat.Builder, + ) { + if (state.paused) { + builder.addAction( + createAction( + context, + RecordingNotificationReceiver.ACTION_RESUME, + REQUEST_CODE_RESUME, + state.resumeActionTitle ?: "Resume", + resolveDrawable(context, state.resumeIconResourceName) ?: android.R.drawable.ic_media_play, + ), + ) + } else { + builder.addAction( + createAction( + context, + RecordingNotificationReceiver.ACTION_PAUSE, + REQUEST_CODE_PAUSE, + state.pauseActionTitle ?: "Pause", + resolveDrawable(context, state.pauseIconResourceName) ?: android.R.drawable.ic_media_pause, + ), + ) } - return builder.build() + if (state.showStopAction) { + builder.addAction( + createAction( + context, + RecordingNotificationReceiver.ACTION_STOP, + REQUEST_CODE_STOP, + state.stopActionTitle ?: "Stop", + resolveDrawable(context, state.stopIconResourceName) ?: R.drawable.stop, + ), + ) + } } - private fun setupPauseResumeIntent(context: Context): Pair { - val pauseResumeIntent = - if (state.paused) { - state.resumeIntent - } else { - state.pauseIntent - } - - val pauseResumePendingIntent = + private fun createAction( + context: Context, + action: String, + requestCode: Int, + title: String, + iconResId: Int, + ): NotificationCompat.Action { + val intent = Intent(action).apply { `package` = context.packageName } + val pendingIntent = PendingIntent.getBroadcast( context, - 0, - pauseResumeIntent!!, + requestCode, + intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + return NotificationCompat.Action(iconResId, title, pendingIntent) + } - val pauseId = - if (state.pauseIconResourceName != null) { - context.resources.getIdentifier(state.pauseIconResourceName, "drawable", context.packageName) - } else { - android.R.drawable.ic_media_pause + // The system chronometer always ticks against wall time, so the recording's paused + // spans are carved out by shifting the base (`startedAtMs`) forward on each resume. + private fun setupChronometer(builder: NotificationCompat.Builder) { + val now = System.currentTimeMillis() + + if (state.usesChronometer && !state.paused) { + if (state.startedAtMs == null) { + state.startedAtMs = now } - val resumeId = - if (state.resumeIconResourceName != null) { - context.resources.getIdentifier(state.resumeIconResourceName, "drawable", context.packageName) - } else { - android.R.drawable.ic_media_play + state.pausedAtMs?.let { pausedAt -> + state.startedAtMs = state.startedAtMs!! + (now - pausedAt) + state.pausedAtMs = null } - - val iconId = if (state.paused) resumeId else pauseId - return pauseResumePendingIntent to iconId - } - - private fun setupRemoteView( - views: List, - pauseResumePendingIntent: PendingIntent, - iconId: Int, - ) { - val iconColor = - if (state.darkTheme) { - Color.WHITE // Dark Mode -> White Icon - } else { - Color.BLACK // Light Mode -> Black Icon + builder + .setWhen(state.startedAtMs!!) + .setShowWhen(true) + .setUsesChronometer(true) + } else { + if (state.usesChronometer && state.paused && state.pausedAtMs == null) { + state.pausedAtMs = now } - for (view in views) { - view.setTextViewText(R.id.notification_title, state.title) - view.setTextViewText(R.id.notification_content, state.contentText) - view.setImageViewResource(R.id.notification_action_btn, iconId) - view.setInt(R.id.notification_action_btn, "setColorFilter", iconColor) - view.setOnClickPendingIntent(R.id.notification_action_btn, pauseResumePendingIntent) + builder + .setUsesChronometer(false) + .setShowWhen(false) } } -// not used currently, left for future reference -// private fun loadBitmapFromUri( -// context: Context, -// uriString: String?, -// ): Bitmap? = -// try { -// val uri = android.net.Uri.parse(uriString) -// val inputStream: InputStream -// if (uri.scheme == "http" || uri.scheme == "https") { -// // web URL -// val connection = java.net.URL(uriString).openConnection() -// connection.doInput = true -// connection.connect() -// inputStream = connection.inputStream -// } else { -// // local files -// inputStream = context.contentResolver.openInputStream(uri)!! -// } -// android.graphics.BitmapFactory.decodeStream(inputStream) -// } catch (e: Exception) { -// Log.e(TAG, "Failed to load bitmap from URI: $uriString", e) -// null -// } - - private fun getBuilder(): NotificationCompat.Builder { - val context = reactContext.get() ?: throw IllegalStateException("React context is null") - if (state.builder == null) { - val openAppIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) - val pendingIntent = PendingIntent.getActivity(context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE) - - state.builder = - NotificationCompat - .Builder(context, channelId) - .setOngoing(true) - .setContentIntent(pendingIntent) - } - if (state.smallIconResourceName == null) { - state.builder!!.setSmallIcon(android.R.drawable.ic_btn_speak_now) + private fun resolveDrawable( + context: Context, + resourceName: String?, + ): Int? { + if (resourceName == null) { + return null } - return state.builder!! - } - - private fun createNotificationChannel(context: ReactApplicationContext) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = - NotificationChannel( - channelId, - "Recording Audio", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Notifications for ongoing audio recordings" - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - } - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - Log.d(TAG, "Notification channel created: $channelId") + val resourceId = context.resources.getIdentifier(resourceName, "drawable", context.packageName) + return if (resourceId != 0) resourceId else null } private fun parseMapFromRN(options: ReadableMap?) { @@ -247,75 +233,104 @@ class RecordingNotification( state.contentText ?: "Audio recording is in progress/paused" } state.smallIconResourceName = - if (options?.hasKey("smallIconResourceName") == - true - ) { + if (options?.hasKey("smallIconResourceName") == true) { options.getString("smallIconResourceName") } else { - state.smallIconResourceName ?: null + state.smallIconResourceName } state.largeIconResourceName = - if (options?.hasKey("largeIconResourceName") == - true - ) { + if (options?.hasKey("largeIconResourceName") == true) { options.getString("largeIconResourceName") } else { - state.largeIconResourceName ?: null + state.largeIconResourceName } state.pauseIconResourceName = - if (options?.hasKey("pauseIconResourceName") == - true - ) { + if (options?.hasKey("pauseIconResourceName") == true) { options.getString("pauseIconResourceName") } else { - state.pauseIconResourceName ?: null + state.pauseIconResourceName } state.resumeIconResourceName = - if (options?.hasKey("resumeIconResourceName") == - true - ) { + if (options?.hasKey("resumeIconResourceName") == true) { options.getString("resumeIconResourceName") } else { - state.resumeIconResourceName ?: null + state.resumeIconResourceName + } + state.stopIconResourceName = + if (options?.hasKey("stopIconResourceName") == true) { + options.getString("stopIconResourceName") + } else { + state.stopIconResourceName + } + state.backgroundColor = if (options?.hasKey("color") == true) options.getInt("color") else state.backgroundColor + state.showStopAction = + if (options?.hasKey("showStopAction") == true) { + options.getBoolean("showStopAction") + } else { + state.showStopAction + } + state.pauseActionTitle = + if (options?.hasKey("pauseActionTitle") == true) { + options.getString("pauseActionTitle") + } else { + state.pauseActionTitle + } + state.resumeActionTitle = + if (options?.hasKey("resumeActionTitle") == true) { + options.getString("resumeActionTitle") + } else { + state.resumeActionTitle + } + state.stopActionTitle = + if (options?.hasKey("stopActionTitle") == true) { + options.getString("stopActionTitle") + } else { + state.stopActionTitle } - state.backgroundColor = if (options?.hasKey("color") == true) options.getInt("color") else state.backgroundColor ?: null + state.deepLinkUri = if (options?.hasKey("deepLinkUri") == true) options.getString("deepLinkUri") else state.deepLinkUri + state.usesChronometer = + if (options?.hasKey("usesChronometer") == true) { + options.getBoolean("usesChronometer") + } else { + state.usesChronometer + } + // Unlike the other options, `paused` resets when absent so the notification never + // sticks in the paused look. state.paused = if (options?.hasKey("paused") == true) options.getBoolean("paused") else false } + private fun createNotificationChannel(context: ReactApplicationContext) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + channelId, + "Recording Audio", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Notifications for ongoing audio recordings" + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + Log.d(TAG, "Notification channel created: $channelId") + } + override fun hide() { val context = reactContext.get() ?: throw IllegalStateException("React context is null") if (state.receiver != null) { context.unregisterReceiver(state.receiver) - context.unregisterComponentCallbacks(this) state.receiver = null } val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(notificationId) state.initialized = false - state.builder = null + state.startedAtMs = null + state.pausedAtMs = null } override fun getNotificationId(): Int = notificationId override fun getChannelId(): String = channelId - - @RequiresApi(Build.VERSION_CODES.O) - override fun onConfigurationChanged(newConfig: Configuration) { - val currentNightMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - if (currentNightMode != state.darkTheme) { - // Theme changed, rebuild notification - state.darkTheme = currentNightMode - val notification = show(state.cachedRNOptions) - val context = reactContext.get() - if (context != null) { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(notificationId, notification) - } - } - } - - @Deprecated("Deprecated in Java") - override fun onLowMemory() { - // left to listen for ui mode changes - } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt index b7ad9d740..32b5e3966 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt @@ -6,14 +6,27 @@ import android.content.Intent import android.util.Log import com.swmansion.audioapi.AudioAPIModule import com.swmansion.audioapi.system.AudioEvent +import com.swmansion.audioapi.system.MediaSessionManager +import com.swmansion.audioapi.system.NativeRecorderControl +import java.util.concurrent.Executors class RecordingNotificationReceiver( private val module: AudioAPIModule, ) : BroadcastReceiver() { companion object { - const val NOTIFICATION_RECORDING_STOPPED = "com.swmansion.audioapi.NOTIFICATION_RECORDING_STOPPED" - const val NOTIFICATION_RECORDING_RESUMED = "com.swmansion.audioapi.NOTIFICATION_RECORDING_RESUMED" + const val ACTION_PAUSE = "com.swmansion.audioapi.RECORDING_NOTIFICATION_PAUSE" + const val ACTION_RESUME = "com.swmansion.audioapi.RECORDING_NOTIFICATION_RESUME" + const val ACTION_STOP = "com.swmansion.audioapi.RECORDING_NOTIFICATION_STOP" + + @Deprecated("Misleading name — it never stopped anything.", ReplaceWith("ACTION_PAUSE")) + const val NOTIFICATION_RECORDING_STOPPED = ACTION_PAUSE + + @Deprecated("Renamed for consistency with the other actions.", ReplaceWith("ACTION_RESUME")) + const val NOTIFICATION_RECORDING_RESUMED = ACTION_RESUME + private const val TAG = "RecordingNotificationReceiver" + + private val controlExecutor = Executors.newSingleThreadExecutor() } override fun onReceive( @@ -21,14 +34,68 @@ class RecordingNotificationReceiver( intent: Intent?, ) { when (intent?.action) { - NOTIFICATION_RECORDING_STOPPED -> { - Log.d(TAG, "Recording stopped via notification") - module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_PAUSE.ordinal, mapOf()) + ACTION_PAUSE -> { + togglePauseNatively(paused = true) + } + + ACTION_RESUME -> { + togglePauseNatively(paused = false) + } + + ACTION_STOP -> { + stopRecordingNatively() } + } + } + + /** + * Every action acts on the recorder natively so the notification keeps working after + * the app task was removed, when no JS listener is reachable. A live runtime is still + * notified through the matching event so it can sync its UI; those handlers calling + * the recorder again is harmless — the recorder ignores same-state transitions. + * + * Runs on an executor because [onReceive] is called on the main thread and the native + * calls take the recorder's locks (stop even blocks on file finalization); [goAsync] + * keeps the process alive meanwhile. + */ + private fun togglePauseNatively(paused: Boolean) { + val pendingResult = goAsync() + controlExecutor.execute { + try { + if (paused) { + NativeRecorderControl.pauseActiveRecording() + module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_PAUSE.ordinal, mapOf()) + } else { + NativeRecorderControl.resumeActiveRecording() + module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_RESUME.ordinal, mapOf()) + } + MediaSessionManager.setRecordingNotificationPaused(paused) + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "Native library unavailable, cannot toggle the recording: ${e.message}", e) + } catch (e: Exception) { + Log.e(TAG, "Error while toggling the recording from the notification: ${e.message}", e) + } finally { + pendingResult.finish() + } + } + } - NOTIFICATION_RECORDING_RESUMED -> { - Log.d(TAG, "Recording resumed via notification") - module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_RESUME.ordinal, mapOf()) + /** See [togglePauseNatively]; stopping additionally hides the notification, which lets + * the foreground service unwind, and stashes the file info for + * `AudioRecorder.takeLastRecordingResult()`. */ + private fun stopRecordingNatively() { + val pendingResult = goAsync() + controlExecutor.execute { + try { + NativeRecorderControl.stopActiveRecording() + module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_STOP.ordinal, mapOf()) + MediaSessionManager.hideRecordingNotification() + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "Native library unavailable, cannot stop the recording: ${e.message}", e) + } catch (e: Exception) { + Log.e(TAG, "Error while stopping the recording from the notification: ${e.message}", e) + } finally { + pendingResult.finish() } } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt index b204e3987..012008844 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt @@ -1,16 +1,15 @@ package com.swmansion.audioapi.system.notification.state -import android.content.Intent -import androidx.core.app.NotificationCompat -import com.facebook.react.bridge.ReadableMap import com.swmansion.audioapi.system.notification.RecordingNotificationReceiver +/** + * Options are sticky: a `show()` call keeps every value the previous call set unless the + * new options override it. The only exception is `paused`, which resets to `false` when + * absent so the notification never sticks in the paused look. + */ data class RecordingNotificationState( - var builder: NotificationCompat.Builder? = null, var receiver: RecordingNotificationReceiver? = null, - var initialized: Boolean, - var pauseIntent: Intent? = null, - var resumeIntent: Intent? = null, + var initialized: Boolean = false, var title: String? = null, var contentText: String? = null, var paused: Boolean = false, @@ -18,7 +17,14 @@ data class RecordingNotificationState( var largeIconResourceName: String? = null, var pauseIconResourceName: String? = null, var resumeIconResourceName: String? = null, + var stopIconResourceName: String? = null, var backgroundColor: Int? = null, - var cachedRNOptions: ReadableMap? = null, - var darkTheme: Boolean, + var showStopAction: Boolean = false, + var pauseActionTitle: String? = null, + var resumeActionTitle: String? = null, + var stopActionTitle: String? = null, + var deepLinkUri: String? = null, + var usesChronometer: Boolean = false, + var startedAtMs: Long? = null, + var pausedAtMs: Long? = null, ) diff --git a/packages/react-native-audio-api/android/src/main/res/layout/btn_round_ripple.xml b/packages/react-native-audio-api/android/src/main/res/layout/btn_round_ripple.xml deleted file mode 100644 index f63d30fc6..000000000 --- a/packages/react-native-audio-api/android/src/main/res/layout/btn_round_ripple.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/packages/react-native-audio-api/android/src/main/res/layout/notification_collapsed.xml b/packages/react-native-audio-api/android/src/main/res/layout/notification_collapsed.xml deleted file mode 100644 index b1f6e9d93..000000000 --- a/packages/react-native-audio-api/android/src/main/res/layout/notification_collapsed.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - diff --git a/packages/react-native-audio-api/android/src/main/res/layout/notification_expanded.xml b/packages/react-native-audio-api/android/src/main/res/layout/notification_expanded.xml deleted file mode 100644 index 15f953d6c..000000000 --- a/packages/react-native-audio-api/android/src/main/res/layout/notification_expanded.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp index 51d33eaaf..8fab0e207 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -28,6 +29,7 @@ AudioRecorderHostObject::AudioRecorderHostObject( #else audioRecorder_ = std::make_shared(audioEventHandlerRegistry); #endif + ActiveRecorderHandle::global().setRecorder(audioRecorder_); promiseVendor_ = std::make_shared(runtime, callInvoker); @@ -51,6 +53,10 @@ AudioRecorderHostObject::AudioRecorderHostObject( addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioRecorderHostObject, inputLatency)); } +AudioRecorderHostObject::~AudioRecorderHostObject() { + ActiveRecorderHandle::global().clearRecorder(audioRecorder_.get()); +} + JSI_HOST_FUNCTION_IMPL(AudioRecorderHostObject, start) { auto fileNameOverride = jsiutils::argToString(runtime, args, count, 0, ""); auto audioRecorder = audioRecorder_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h index ec534ca00..02174d9e6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h @@ -18,6 +18,7 @@ class AudioRecorderHostObject : public HostObject { const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker); + ~AudioRecorderHostObject() override; JSI_HOST_FUNCTION_DECL(start); JSI_HOST_FUNCTION_DECL(stop); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp index bcfa85d5b..5936ff32a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp @@ -148,6 +148,8 @@ AudioEvent audioEventFromString(const std::string &event) { return AudioEvent::BUFFER_ENDED; if (event == "recorderError") return AudioEvent::RECORDER_ERROR; + if (event == "recordingNotificationStop") + return AudioEvent::RECORDING_NOTIFICATION_STOP; throw std::invalid_argument("Unknown audio event: " + event); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp new file mode 100644 index 000000000..39ccfe990 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include +#include +#include + +namespace audioapi { + +ActiveRecorderHandle &ActiveRecorderHandle::global() { + static ActiveRecorderHandle handle; + return handle; +} + +void ActiveRecorderHandle::setRecorder(const std::shared_ptr &recorder) { + std::scoped_lock lock(mutex_); + recorder_ = recorder; +} + +void ActiveRecorderHandle::clearRecorder(const AudioRecorder *recorder) { + std::scoped_lock lock(mutex_); + auto current = recorder_.lock(); + if (current != nullptr && current.get() != recorder) { + return; + } + recorder_.reset(); +} + +bool ActiveRecorderHandle::isRecordingOngoing() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + return recorder != nullptr && !recorder->isIdle(); +} + +bool ActiveRecorderHandle::pauseActiveRecording() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + if (recorder == nullptr || !recorder->isRecording()) { + return false; + } + recorder->pause(); + return true; +} + +bool ActiveRecorderHandle::resumeActiveRecording() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + if (recorder == nullptr || !recorder->isPaused()) { + return false; + } + recorder->resume(); + return true; +} + +bool ActiveRecorderHandle::stopActiveRecording() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + if (recorder == nullptr || recorder->isIdle()) { + return false; + } + + // stop() blocks on file finalization and the recorder's destructor may call + // clearRecorder() concurrently, so mutex_ must not be held around it. + auto result = recorder->stop(); + if (!result.is_ok()) { + return false; + } + + auto [paths, size, duration] = result.unwrap(); + if (!paths.empty()) { + std::scoped_lock lock(mutex_); + lastResult_ = + RecordingStopResult{.paths = std::move(paths), .size = size, .duration = duration}; + } + return true; +} + +std::optional ActiveRecorderHandle::takeLastRecordingResult() { + std::scoped_lock lock(mutex_); + auto result = std::move(lastResult_); + lastResult_.reset(); + return result; +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h new file mode 100644 index 000000000..b442dc694 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace audioapi { + +class AudioRecorder; + +struct RecordingStopResult { + std::vector paths; + double size; + double duration; +}; + +/// @brief Process-global handle to the live AudioRecorder, reachable without a JS runtime. +/// +/// The recorder is otherwise owned solely by its JS-side host object, so platform code +/// (e.g. the Android recording-notification STOP action) has no way to reach it once the +/// JS runtime is unreachable, and a fresh JS context has no way to learn that a recording +/// outlived the app UI. This handle closes both gaps: it can stop the recording natively +/// and it stashes the resulting file info until JS collects it. +/// +/// Assumes at most one AudioRecorder is alive at a time; setting a new recorder replaces +/// the previous one. +class ActiveRecorderHandle { + public: + static ActiveRecorderHandle &global(); + + void setRecorder(const std::shared_ptr &recorder); + + /// @brief Detaches the recorder, but only if the slot still holds @p recorder. + void clearRecorder(const AudioRecorder *recorder); + + /// @brief True while a recording session is active; a paused recording counts as + /// ongoing because it still owns an open output file. + bool isRecordingOngoing(); + + /// @return true if an actively recording session was paused by this call. + bool pauseActiveRecording(); + + /// @return true if a paused session was resumed by this call. + bool resumeActiveRecording(); + + /// @brief Stops a non-idle recording and stashes its file info for + /// takeLastRecordingResult(). Blocks until the output file is finalized — + /// never call on a UI thread. + /// @return true if this call stopped the recording. Losing a race with a + /// JS-initiated stop() returns false; the JS promise delivers that result. + bool stopActiveRecording(); + + /// @brief Consume-once: returns the file info stashed by stopActiveRecording() + /// and clears it, or std::nullopt when nothing is stashed. + std::optional takeLastRecordingResult(); + + private: + std::mutex mutex_; + std::weak_ptr recorder_; + std::optional lastResult_; +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp index 7d0cca29c..5f83332bd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp @@ -76,10 +76,6 @@ void AudioRecorderCallback::invokeCallback( framesEmitted_ += numFrames; } -void AudioRecorderCallback::assignOnErrorCallbackId(uint64_t callbackId) { - errorEvent_.assignCallbackId(callbackId); -} - /// @brief Invokes the error callback with the provided message. /// @param message The error message to be sent to the callback. void AudioRecorderCallback::invokeOnErrorCallback(const std::string &message) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h index 111e7e27e..b4582c8be 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h @@ -42,7 +42,11 @@ class AudioRecorderCallback { void clearOnErrorCallback() { assignOnErrorCallbackId(0); } - void assignOnErrorCallbackId(uint64_t callbackId); + // Defined inline so AudioRecorder.cpp doesn't drag this class's whole + // translation unit (and its HostObject dependency) into the C++ test build. + void assignOnErrorCallbackId(uint64_t callbackId) { + errorEvent_.assignCallbackId(callbackId); + } void invokeOnErrorCallback(const std::string &message); protected: diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h index 2f669d76a..1396a7143 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h @@ -28,5 +28,6 @@ enum class AudioEvent : uint8_t { POSITION_CHANGED, BUFFER_ENDED, RECORDER_ERROR, + RECORDING_NOTIFICATION_STOP, }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp new file mode 100644 index 000000000..6bce4cd06 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp @@ -0,0 +1,192 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace audioapi; + +// NOLINTBEGIN + +namespace { + +class FakeAudioRecorder : public AudioRecorder { + public: + FakeAudioRecorder() : AudioRecorder(nullptr) {} + + std::vector stopPaths{"file:///tmp/recording.m4a"}; + std::atomic stopCount{0}; + + Result start(const std::string &) override { + state_ = RecorderState::Recording; + return Ok(None); + } + + // Mirrors AndroidAudioRecorder::stop(): under its locks exactly one caller + // transitions out of a non-idle state and closes the file; the loser errs. + Result, double, double>, std::string> stop() override { + if (state_.exchange(RecorderState::Idle) == RecorderState::Idle) { + return Err(std::string("Recorder is not in recording state.")); + } + stopCount += 1; + return Ok(std::make_tuple(stopPaths, 1.5, 10.0)); + } + + Result enableFileOutput(std::shared_ptr) override { + return Ok(None); + } + void disableFileOutput() override {} + + void pause() override { + state_ = RecorderState::Paused; + } + void resume() override { + state_ = RecorderState::Recording; + } + + void connect(const std::shared_ptr &) override {} + void disconnect() override {} + + Result setOnAudioReadyCallback(float, size_t, int, uint64_t) override { + return Ok(None); + } + void clearOnAudioReadyCallback() override {} + + bool isRecording() const override { + return state_ == RecorderState::Recording; + } + bool isPaused() const override { + return state_ == RecorderState::Paused; + } + bool isIdle() const override { + return state_ == RecorderState::Idle; + } + + [[nodiscard]] double getInputLatency() const override { + return 0.0; + } +}; + +} // namespace + +TEST(ActiveRecorderHandleTest, EmptySlotReportsNoRecordingAndStopsNothing) { + ActiveRecorderHandle handle; + + EXPECT_FALSE(handle.isRecordingOngoing()); + EXPECT_FALSE(handle.stopActiveRecording()); + EXPECT_FALSE(handle.takeLastRecordingResult().has_value()); +} + +TEST(ActiveRecorderHandleTest, IdleRecorderIsNotOngoing) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + + EXPECT_FALSE(handle.isRecordingOngoing()); + EXPECT_FALSE(handle.stopActiveRecording()); +} + +TEST(ActiveRecorderHandleTest, RecordingAndPausedCountAsOngoing) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + + recorder->start(""); + EXPECT_TRUE(handle.isRecordingOngoing()); + + recorder->pause(); + EXPECT_TRUE(handle.isRecordingOngoing()); +} + +TEST(ActiveRecorderHandleTest, PauseAndResumeActOnlyInMatchingStates) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + + EXPECT_FALSE(handle.pauseActiveRecording()); + EXPECT_FALSE(handle.resumeActiveRecording()); + + recorder->start(""); + EXPECT_FALSE(handle.resumeActiveRecording()); + EXPECT_TRUE(handle.pauseActiveRecording()); + EXPECT_TRUE(recorder->isPaused()); + + EXPECT_FALSE(handle.pauseActiveRecording()); + EXPECT_TRUE(handle.resumeActiveRecording()); + EXPECT_TRUE(recorder->isRecording()); +} + +TEST(ActiveRecorderHandleTest, StopStashesResultForSingleConsumption) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + + EXPECT_TRUE(handle.stopActiveRecording()); + EXPECT_FALSE(handle.isRecordingOngoing()); + + auto result = handle.takeLastRecordingResult(); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->paths, recorder->stopPaths); + EXPECT_DOUBLE_EQ(result->size, 1.5); + EXPECT_DOUBLE_EQ(result->duration, 10.0); + + EXPECT_FALSE(handle.takeLastRecordingResult().has_value()); +} + +TEST(ActiveRecorderHandleTest, StopWithoutFileOutputStashesNothing) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + recorder->stopPaths.clear(); + handle.setRecorder(recorder); + recorder->start(""); + + EXPECT_TRUE(handle.stopActiveRecording()); + EXPECT_FALSE(handle.takeLastRecordingResult().has_value()); +} + +TEST(ActiveRecorderHandleTest, ExpiredRecorderReportsNoRecording) { + ActiveRecorderHandle handle; + { + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + } + + EXPECT_FALSE(handle.isRecordingOngoing()); + EXPECT_FALSE(handle.stopActiveRecording()); +} + +TEST(ActiveRecorderHandleTest, ClearRecorderIgnoresForeignPointer) { + ActiveRecorderHandle handle; + auto current = std::make_shared(); + auto other = std::make_shared(); + handle.setRecorder(current); + current->start(""); + + handle.clearRecorder(other.get()); + EXPECT_TRUE(handle.isRecordingOngoing()); + + handle.clearRecorder(current.get()); + EXPECT_FALSE(handle.isRecordingOngoing()); +} + +TEST(ActiveRecorderHandleTest, ConcurrentStopsCloseTheFileExactlyOnce) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + + std::thread nativeStop([&handle] { handle.stopActiveRecording(); }); + std::thread jsStop([&recorder] { recorder->stop(); }); + nativeStop.join(); + jsStop.join(); + + EXPECT_EQ(recorder->stopCount, 1); +} + +// NOLINTEND diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index 3f1060233..dc2cfa510 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -69,14 +69,50 @@ export interface RecordingNotificationInfo { paused?: boolean; smallIconResourceName?: string; largeIconResourceName?: string; + /** + * Action icon; ignored on Android 12+ where the system renders text-only + * actions. + */ pauseIconResourceName?: string; + /** + * Action icon; ignored on Android 12+ where the system renders text-only + * actions. + */ resumeIconResourceName?: string; color?: number; + /** + * Shows a stop action that ends the recording natively — it works even when + * the app task has been removed and JS is unreachable. A live app is + * additionally notified through the `recordingNotificationStop` event. + * Default: false. + */ + showStopAction?: boolean; + /** + * Action icon; ignored on Android 12+ where the system renders text-only + * actions. + */ + stopIconResourceName?: string; + /** Label of the pause action. Default: 'Pause'. */ + pauseActionTitle?: string; + /** Label of the resume action. Default: 'Resume'. */ + resumeActionTitle?: string; + /** Label of the stop action. Default: 'Stop'. */ + stopActionTitle?: string; + /** + * URI attached to the notification tap intent, e.g. `myapp://record`. + * Delivered through React Native's `Linking` (initial URL on cold start, + * `url` event otherwise), so it can route to a specific screen. Without it, + * tapping the notification opens the app's launcher activity. + */ + deepLinkUri?: string; + /** Shows the elapsed recording time in the notification. Default: false. */ + usesChronometer?: boolean; } export interface RecordingNotificationEvent { recordingNotificationPause: EventEmptyType; recordingNotificationResume: EventEmptyType; + recordingNotificationStop: EventEmptyType; } export type PlaybackNotificationEventName = keyof PlaybackNotificationEvent; From f77500c6942b10588a149d135dc2a7153f239920 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 19 Aug 2026 16:14:31 +0200 Subject: [PATCH 3/4] feat: small improvements --- .claude/skills/post-work-checks/SKILL.md | 2 -- .../system/notification/RecordingNotificationReceiver.kt | 6 ------ 2 files changed, 8 deletions(-) diff --git a/.claude/skills/post-work-checks/SKILL.md b/.claude/skills/post-work-checks/SKILL.md index 3c038935f..951f994fc 100644 --- a/.claude/skills/post-work-checks/SKILL.md +++ b/.claude/skills/post-work-checks/SKILL.md @@ -117,8 +117,6 @@ yarn workspace react-native-audio-api run test:cpp yarn test # from monorepo root — runs test:js + test:cpp ``` -**Gotcha**: jest resolves `react-native-audio-api/mock` through `mock/package.json` → the built `lib/` output, not `src/`. After editing `src/mock/` (or any API the tests import), run `yarn build` in the package first, or tests exercise the stale build ("X is not a function" for newly added members). - **When**: after any change to C++ files or TypeScript files in `src/`. Prefer this for a quick local test loop covering both TS and C++ logic; run `yarn validate:fast` before opening a PR. ### AudioEvent enum sync check diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt index 32b5e3966..188022a8b 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt @@ -18,12 +18,6 @@ class RecordingNotificationReceiver( const val ACTION_RESUME = "com.swmansion.audioapi.RECORDING_NOTIFICATION_RESUME" const val ACTION_STOP = "com.swmansion.audioapi.RECORDING_NOTIFICATION_STOP" - @Deprecated("Misleading name — it never stopped anything.", ReplaceWith("ACTION_PAUSE")) - const val NOTIFICATION_RECORDING_STOPPED = ACTION_PAUSE - - @Deprecated("Renamed for consistency with the other actions.", ReplaceWith("ACTION_RESUME")) - const val NOTIFICATION_RECORDING_RESUMED = ACTION_RESUME - private const val TAG = "RecordingNotificationReceiver" private val controlExecutor = Executors.newSingleThreadExecutor() From 3c0c60c9056a6c8378ffdbe43d98e757e165aa25 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 19 Aug 2026 16:56:15 +0200 Subject: [PATCH 4/4] feat: small improvements v2 --- apps/common-app/src/demos/Record/Record.tsx | 2 -- .../system/recording-notification-manager.mdx | 5 +--- .../android/core/AndroidAudioRecorder.cpp | 2 -- .../notification/RecordingNotification.kt | 24 +------------------ .../state/RecordingNotificationState.kt | 3 --- .../src/system/notification/types.ts | 15 ------------ 6 files changed, 2 insertions(+), 49 deletions(-) diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 6bcb7df73..6aea486f3 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -51,8 +51,6 @@ const Record: FC = () => { contentText: paused ? 'Paused recording' : 'Recording...', paused, smallIconResourceName: 'logo', - pauseIconResourceName: 'pause', - resumeIconResourceName: 'resume', color: 0xff6200, }); }; diff --git a/packages/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx index e6e88fa2f..e0633c574 100644 --- a/packages/audiodocs/docs/system/recording-notification-manager.mdx +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -87,7 +87,7 @@ Resource name is a path to resource placed in res/drawable folder. It has to be ::: :::caution -The notification uses the standard Android template, so its exact look varies between devices and Android versions. On Android 12+ the system renders actions as text buttons — the `pauseIconResourceName`, `resumeIconResourceName` and `stopIconResourceName` icons only show up on older versions; use the `*ActionTitle` options to control the visible labels. +The notification uses the standard Android template, so its exact look varies between devices and Android versions. ::: ### `hide` @@ -126,11 +126,8 @@ interface RecordingNotificationInfo { paused?: boolean; // flag indicating whether to display the pause or the resume action smallIconResourceName?: string; largeIconResourceName?: string; - pauseIconResourceName?: string; // ignored on Android 12+ - resumeIconResourceName?: string; // ignored on Android 12+ color?: number; showStopAction?: boolean; // shows the native stop action, default: false - stopIconResourceName?: string; // ignored on Android 12+ pauseActionTitle?: string; // default: 'Pause' resumeActionTitle?: string; // default: 'Resume' stopActionTitle?: string; // default: 'Stop' diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp index 8fba56109..4b2253767 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp @@ -598,8 +598,6 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re auto streamResult = openAudioStream(); if (!streamResult.is_ok()) { - // Deliberately left Idle (by cleanup()): restoring Paused here would let a later - // resume() start a stream that no longer exists. uint64_t callbackId = errorCallbackId_.load(std::memory_order_acquire); if (audioEventHandlerRegistry_ == nullptr || callbackId == 0) { diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index cdecf42b2..74967a8bf 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -140,7 +140,6 @@ class RecordingNotification( RecordingNotificationReceiver.ACTION_RESUME, REQUEST_CODE_RESUME, state.resumeActionTitle ?: "Resume", - resolveDrawable(context, state.resumeIconResourceName) ?: android.R.drawable.ic_media_play, ), ) } else { @@ -150,7 +149,6 @@ class RecordingNotification( RecordingNotificationReceiver.ACTION_PAUSE, REQUEST_CODE_PAUSE, state.pauseActionTitle ?: "Pause", - resolveDrawable(context, state.pauseIconResourceName) ?: android.R.drawable.ic_media_pause, ), ) } @@ -162,7 +160,6 @@ class RecordingNotification( RecordingNotificationReceiver.ACTION_STOP, REQUEST_CODE_STOP, state.stopActionTitle ?: "Stop", - resolveDrawable(context, state.stopIconResourceName) ?: R.drawable.stop, ), ) } @@ -173,7 +170,6 @@ class RecordingNotification( action: String, requestCode: Int, title: String, - iconResId: Int, ): NotificationCompat.Action { val intent = Intent(action).apply { `package` = context.packageName } val pendingIntent = @@ -183,7 +179,7 @@ class RecordingNotification( intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - return NotificationCompat.Action(iconResId, title, pendingIntent) + return NotificationCompat.Action(null, title, pendingIntent) } // The system chronometer always ticks against wall time, so the recording's paused @@ -244,24 +240,6 @@ class RecordingNotification( } else { state.largeIconResourceName } - state.pauseIconResourceName = - if (options?.hasKey("pauseIconResourceName") == true) { - options.getString("pauseIconResourceName") - } else { - state.pauseIconResourceName - } - state.resumeIconResourceName = - if (options?.hasKey("resumeIconResourceName") == true) { - options.getString("resumeIconResourceName") - } else { - state.resumeIconResourceName - } - state.stopIconResourceName = - if (options?.hasKey("stopIconResourceName") == true) { - options.getString("stopIconResourceName") - } else { - state.stopIconResourceName - } state.backgroundColor = if (options?.hasKey("color") == true) options.getInt("color") else state.backgroundColor state.showStopAction = if (options?.hasKey("showStopAction") == true) { diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt index 012008844..488d31bb7 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt @@ -15,9 +15,6 @@ data class RecordingNotificationState( var paused: Boolean = false, var smallIconResourceName: String? = null, var largeIconResourceName: String? = null, - var pauseIconResourceName: String? = null, - var resumeIconResourceName: String? = null, - var stopIconResourceName: String? = null, var backgroundColor: Int? = null, var showStopAction: Boolean = false, var pauseActionTitle: String? = null, diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index dc2cfa510..d6f868c42 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -69,16 +69,6 @@ export interface RecordingNotificationInfo { paused?: boolean; smallIconResourceName?: string; largeIconResourceName?: string; - /** - * Action icon; ignored on Android 12+ where the system renders text-only - * actions. - */ - pauseIconResourceName?: string; - /** - * Action icon; ignored on Android 12+ where the system renders text-only - * actions. - */ - resumeIconResourceName?: string; color?: number; /** * Shows a stop action that ends the recording natively — it works even when @@ -87,11 +77,6 @@ export interface RecordingNotificationInfo { * Default: false. */ showStopAction?: boolean; - /** - * Action icon; ignored on Android 12+ where the system renders text-only - * actions. - */ - stopIconResourceName?: string; /** Label of the pause action. Default: 'Pause'. */ pauseActionTitle?: string; /** Label of the resume action. Default: 'Resume'. */