From 7a2de61fa5572d7cd16adb8199045c8047c7702b 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/9] 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 4c20d4c2d..e0aa17681 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 - **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`). +- **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 1c7c5b8b2..726617191 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 ac3fc607fc08d1e0b6fb625c1de0328abc858878 Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 17 Aug 2026 13:56:59 +0200 Subject: [PATCH 2/9] 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 | 3 +- .../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, 934 insertions(+), 347 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 1e13b88b4..6dc02952a 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -132,6 +132,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 e0aa17681..f1715d44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,9 @@ 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 -- **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`). - **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 - iOS: `ios/audioapi/ios/AudioAPIModule.mm` 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`](../inputs/audio-recorder.mdx) 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 1a24cf8fe..8fba56109 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 @@ -591,11 +591,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) { @@ -610,8 +614,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 398a93c0f..a7c65d3ee 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 @@ -26,4 +26,5 @@ enum class AudioEvent { BUFFER_ENDED, RECORDER_ERROR, BUFFERING_STATE_CHANGE, + 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 900ad1a04..940f10bdb 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 { @@ -270,5 +271,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 ab999d80c..7b42d1eab 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 @@ -31,6 +32,7 @@ AudioRecorderHostObject::AudioRecorderHostObject( #else audioRecorder_ = std::make_shared(audioEventHandlerRegistry, options); #endif + ActiveRecorderHandle::global().setRecorder(audioRecorder_); promiseVendor_ = std::make_shared(runtime, callInvoker); @@ -54,6 +56,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 c2bd4e8eb..801e95022 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 @@ -20,6 +20,7 @@ class AudioRecorderHostObject : public HostObject { jsi::Runtime *runtime, const std::shared_ptr &callInvoker, AudioRecorderOptions options); + ~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 a2259b7db..e1b7c212c 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 @@ -150,6 +150,8 @@ AudioEvent audioEventFromString(const std::string &event) { return AudioEvent::RECORDER_ERROR; if (event == "bufferingStateChanged") return AudioEvent::BUFFERING_STATE_CHANGE; + 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 e11dc5262..f154fef42 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 @@ -29,5 +29,6 @@ enum class AudioEvent : uint8_t { BUFFER_ENDED, RECORDER_ERROR, BUFFERING_STATE_CHANGE, + 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 16e295efc..f41d5612b 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -77,14 +77,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 fffc428edc51c73b6b9283876eac8cb493db57e1 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 19 Aug 2026 16:14:31 +0200 Subject: [PATCH 3/9] 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 285e33626d3bcc7d24e381e3d58a4d385f13255d Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 19 Aug 2026 16:56:15 +0200 Subject: [PATCH 4/9] 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 3a6fe5fff..b0c065905 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 f41d5612b..c3312eaf4 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -77,16 +77,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 @@ -95,11 +85,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'. */ From 7e70c71a8956c63181b0084a0c93f402d04368a8 Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 13:25:10 +0200 Subject: [PATCH 5/9] fix: ci --- packages/audiodocs/CLAUDE.md | 9 +++++++++ packages/audiodocs/docs/inputs/audio-recorder.mdx | 8 ++++---- packages/audiodocs/docs/other/audio-api-plugin.mdx | 2 +- .../docs/system/recording-notification-manager.mdx | 4 ++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/audiodocs/CLAUDE.md b/packages/audiodocs/CLAUDE.md index d84e23158..fee64e75e 100644 --- a/packages/audiodocs/CLAUDE.md +++ b/packages/audiodocs/CLAUDE.md @@ -88,6 +88,15 @@ their own version. Do not "fix" them in the snapshot; fix `docs/` and let the ne `onBrokenLinks` and `onBrokenAnchors` are set to `throw`, so `yarn build` catches a bad relative path — but it cannot catch an absolute link leaking a Next reader into Latest. +An absolute link is also how a `Next`-only anchor breaks the build: the target section exists in +`docs/` but not yet in the `Latest` snapshot it links into, so `onBrokenAnchors` throws. + +### Anchors come from headings only + +Docusaurus collects link targets from heading ids. A hand-rolled `` renders +but is not collected, so `](#foo)` fails the build. To link to something smaller than a section, +link to the heading that contains it. + ## Sidebar / Navigation Sidebar is **fully autogenerated** from the folder structure — no edits to `sidebars.js` needed. diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 726617191..5793052e0 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -99,7 +99,7 @@ By default the foreground service stops when the user swipes the app away from t - Set the `androidFSStopWithTask` option of the [expo plugin](/docs/other/audio-api-plugin#androidfsstopwithtask) to `false`: + Set the `androidFSStopWithTask` option of the [expo plugin](../other/audio-api-plugin#androidfsstopwithtask) to `false`: ```json { @@ -127,12 +127,12 @@ By default the foreground service stops when the user swipes the app away from t 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. +- The foreground service only exists while a library notification is shown. Call [`RecordingNotificationManager.show()`](../system/recording-notification-manager.mdx#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). +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`](#audiorecorderfileoptions) and [`rotateIntervalBytes`](#audiorecorderfileoptions). ::: ## Examples @@ -732,7 +732,7 @@ interface AudioRecorderFileOptions { - `directory` - Either `FileDirectory.Cache` or `FileDirectory.Document` (default: `FileDirectory.Cache`). Determines the system directory that the file will be saved to. - `subDirectory` - If configured it will create the recording inside requested directory (default: `undefined`). - `fileNamePrefix` - Prefix of the recording files without the unique ID (default: `recording`). -- `androidFlushIntervalMs` - How often the recorder should force the system to write data to the device storage (default: `500`). +- `androidFlushIntervalMs` - How often the recorder should force the system to write data to the device storage (default: `500`). - Lower values are good for crash-resilience and are more memory friendly. - Higher values are more battery - and storage-efficient. diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index c32bbc5ca..025ff6ce3 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -144,5 +144,5 @@ Controls the `android:stopWithTask` attribute of the Foreground Service injected 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. +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](../inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. ::: diff --git a/packages/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx index b0c065905..0a02ccf72 100644 --- a/packages/audiodocs/docs/system/recording-notification-manager.mdx +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -47,10 +47,10 @@ 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. +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](../inputs/audio-recorder.mdx#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. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through `AudioRecorder.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 From 1f36f538ba92355aef616cf63cbf1fbf3dcabcc4 Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 13:39:38 +0200 Subject: [PATCH 6/9] feat: slight improvements --- .../java/com/swmansion/audioapi/system/MediaSessionManager.kt | 2 +- .../audioapi/system/notification/NotificationRegistry.kt | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) 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 940f10bdb..9f30ca61c 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 @@ -280,7 +280,7 @@ object MediaSessionManager { if (!::notificationRegistry.isInitialized) { return } - notificationRegistry.hideNotificationByNotificationId(RecordingNotification.ID) + notificationRegistry.hideNotification(RecordingNotification.ID) } /** 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 c94870ec0..087c71ded 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,6 +1,5 @@ package com.swmansion.audioapi.system.notification -import android.annotation.SuppressLint import android.app.Notification import android.util.Log import androidx.annotation.RequiresPermission @@ -113,7 +112,7 @@ class NotificationRegistry( * * @param id The Android notification ID, e.g. [RecordingNotification.ID] */ - fun hideNotificationByNotificationId(id: Int) { + fun hideNotification(id: Int) { notifications.entries .firstOrNull { it.value.getNotificationId() == id } ?.let { hideNotification(it.key) } @@ -125,7 +124,6 @@ class NotificationRegistry( * 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 { From 57630ccbcfc0d8f8552926216ac2c3d2cf198eef Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 17 Aug 2026 13:57:42 +0200 Subject: [PATCH 7/9] feat: let a fresh js context resync with an ongoing native recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With stopWithTask=false a recording outlives the app UI, but a remounted screen (or relaunched app) had no way to learn about it — recorder state was only reachable through the instance that started it: - new JSI globals backed by ActiveRecorderHandle, surfaced as statics: AudioRecorder.isRecordingOngoing() and the consume-once AudioRecorder.takeLastRecordingResult() for files finalized by the notification stop action (mock parity + jest coverage included) - Record demo mounts directly in the live recorder's state, picks up natively stopped files, keeps the recording alive across screen exits and only enables file output when no session is ongoing (re-enabling mid-recording replaces the writer and resets the duration) - deep-link routing for the notification tap (react-navigation linking), duration displays seeded from the recorder instead of assuming a fresh session, and RecordingTime rewritten to plain state — the animated-prop binding went stale on the frozen value while paused and showed zeros Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- apps/common-app/src/App.tsx | 13 +- apps/common-app/src/demos/Record/Record.tsx | 106 ++++++++-- .../src/demos/Record/RecordingTime.tsx | 72 +++---- .../demos/Record/RecordingVisualization.tsx | 193 ++---------------- .../src/demos/Record/TimeStream.tsx | 14 +- .../common-app/src/demos/Record/constants.tsx | 2 - .../audiodocs/docs/inputs/audio-recorder.mdx | 46 +++++ .../cpp/audioapi/AudioAPIModuleInstaller.h | 43 ++++ .../src/AudioAPIModule/globals.d.ts | 6 +- .../src/core/AudioRecorder.ts | 21 ++ .../react-native-audio-api/src/mock/index.ts | 15 +- .../react-native-audio-api/tests/mock.test.ts | 17 ++ 13 files changed, 298 insertions(+), 252 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1715d44e..3d6d4bed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +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 +- **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 for `AudioRecorder.takeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes for a recording that outlived the UI - **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/common-app/src/App.tsx b/apps/common-app/src/App.tsx index ec6f13d15..aad636d1d 100644 --- a/apps/common-app/src/App.tsx +++ b/apps/common-app/src/App.tsx @@ -181,10 +181,21 @@ const MainTabsScreen: FC = () => { ); }; +// Routes notification taps (e.g. the recording notification's `deepLinkUri`) +// straight to the right screen instead of the app's entry screen. +const linking = { + prefixes: ['audioapi-example://'], + config: { + screens: { + RecordDemo: 'record', + }, + }, +}; + const App: FC = () => { return ( - + { - const [state, setState] = useState(RecordingState.Idle); + // A recording can outlive this screen (and, with `stopWithTask: false`, the whole + // app UI). Mounting directly in the right state lets every child initialize from + // the live recorder instead of transitioning out of a transient Idle render. + const [state, setState] = useState(() => { + if (!AudioRecorder.isRecordingOngoing()) { + return RecordingState.Idle; + } + return Recorder.isPaused() + ? RecordingState.Paused + : RecordingState.Recording; + }); const [hasPermissions, setHasPermissions] = useState(false); const [recordedBuffer, setRecordedBuffer] = useState( null @@ -52,6 +64,10 @@ const Record: FC = () => { paused, smallIconResourceName: 'logo', color: 0xff6200, + showStopAction: true, + stopIconResourceName: 'stop', + deepLinkUri: 'audioapi-example://record', + usesChronometer: true, }); }; @@ -116,6 +132,23 @@ const Record: FC = () => { setState(RecordingState.Recording); }, []); + const loadRecordedAudio = useCallback( + async (paths: string[]) => { + setState(RecordingState.Loading); + + // const outputPath = paths[0].replace(/[^/]+$/, 'recording.wav'); + + // const finalPath = await concatAudioFiles(paths, outputPath); + const finalPath = paths[0]; + const audioBuffer = await audioContext.decodeAudioData(finalPath); + setRecordedBuffer(audioBuffer); + + setState(RecordingState.ReadyToPlay); + currentPositionSV.value = 0; + }, + [currentPositionSV] + ); + const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); RecordingNotificationManager.hide(); @@ -128,15 +161,22 @@ const Record: FC = () => { return; } - const outputPath = info.paths[0].replace(/[^/]+$/, 'recording.m4a'); + await loadRecordedAudio(info.paths); + }, [loadRecordedAudio]); - const finalPath = await concatAudioFiles(info.paths, outputPath); - const audioBuffer = await audioContext.decodeAudioData(finalPath); - setRecordedBuffer(audioBuffer); + // The stop action already stopped the recorder natively and hid the notification; + // here we only pick up the resulting files and sync the UI. + const onStopRecordingFromNotification = useCallback(async () => { + const info = AudioRecorder.takeLastRecordingResult(); - setState(RecordingState.ReadyToPlay); - currentPositionSV.value = 0; - }, []); + if (!info || info.paths.length === 0) { + setRecordedBuffer(null); + setState(RecordingState.Idle); + return; + } + + await loadRecordedAudio(info.paths); + }, [loadRecordedAudio]); const onPlayRecording = useCallback(() => { if (state !== RecordingState.ReadyToPlay) { @@ -227,11 +267,19 @@ const Record: FC = () => { useEffect(() => { (async () => { - const permissionStatus = await AudioManager.checkRecordingPermissions(); + const recordingPermissionStatus = await AudioManager.checkRecordingPermissions(); - if (permissionStatus === 'Granted') { + if (recordingPermissionStatus === 'Granted') { setHasPermissions(true); } + + const notificationPermissionStatus = await AudioManager.checkNotificationPermissions(); + if (notificationPermissionStatus !== 'Granted') { + const result = await AudioManager.requestNotificationPermissions(); + if (result !== 'Granted') { + console.warn('Notification permissions are not granted'); + } + } })(); }, []); @@ -252,22 +300,46 @@ const Record: FC = () => { } ); + const stopListener = RecordingNotificationManager.addEventListener( + 'recordingNotificationStop', + () => { + console.log('Notification stop action received'); + onStopRecordingFromNotification(); + } + ); + return () => { pauseListener.remove(); resumeListener.remove(); - RecordingNotificationManager.hide(); + stopListener.remove(); }; - }, [onPauseRecording, onResumeRecording]); + }, [onPauseRecording, onResumeRecording, onStopRecordingFromNotification]); + // An ongoing recording is picked up by the state initializer above; here we only + // collect the files of a recording that was stopped natively (notification stop + // action) while this screen was unmounted. useEffect(() => { - Recorder.enableFileOutput({ rotateIntervalBytes: 1_000_000, format: FileFormat.M4A }); + if (AudioRecorder.isRecordingOngoing()) { + return; + } + + const info = AudioRecorder.takeLastRecordingResult(); + if (info && info.paths.length > 0) { + loadRecordedAudio(info.paths); + } + }, [loadRecordedAudio]); + + useEffect(() => { + // Re-enabling file output during an ongoing recording replaces the file writer, + // which starts a new file and resets the duration — skip it when resyncing. + if (!AudioRecorder.isRecordingOngoing()) { + Recorder.enableFileOutput({ format: FileFormat.Wav }); + } return () => { + // The recording and its notification intentionally stay alive when leaving this + // screen; they can be stopped from the notification or after coming back. stopPlayback(); - Recorder.disableFileOutput(); - Recorder.stop(); - AudioManager.setAudioSessionActivity(false); - RecordingNotificationManager.hide(); }; }, [stopPlayback]); diff --git a/apps/common-app/src/demos/Record/RecordingTime.tsx b/apps/common-app/src/demos/Record/RecordingTime.tsx index d354b1b08..5f1cd31ac 100644 --- a/apps/common-app/src/demos/Record/RecordingTime.tsx +++ b/apps/common-app/src/demos/Record/RecordingTime.tsx @@ -1,71 +1,53 @@ -import React, { useEffect } from 'react'; -import { StyleSheet, TextInput } from 'react-native'; -import Animated, { - useAnimatedProps, - useSharedValue, -} from 'react-native-reanimated'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet, Text } from 'react-native'; import { audioRecorder as Recorder } from '../../singletons'; import { colors } from '../../styles'; import { RecordingState } from './types'; -const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); +const IDLE_DURATION = '00:00:000'; + +function formatDuration(elapsedSeconds: number) { + const minutes = Math.floor((elapsedSeconds % 3600) / 60) + .toString() + .padStart(2, '0'); + const seconds = Math.floor(elapsedSeconds % 60) + .toString() + .padStart(2, '0'); + const milliseconds = Math.floor((elapsedSeconds % 1) * 1000) + .toString() + .padStart(3, '0'); + + return `${minutes}:${seconds}:${milliseconds}`; +} interface RecordingTimeProps { state: RecordingState; } const RecordingTime: React.FC = ({ state }) => { - const durationStringSV = useSharedValue('00:00:000'); - const isMountedSV = useSharedValue(true); + const [durationString, setDurationString] = useState(IDLE_DURATION); useEffect(() => { - isMountedSV.value = true; if (![RecordingState.Recording, RecordingState.Paused].includes(state)) { - durationStringSV.value = '00:00:00'; + setDurationString(IDLE_DURATION); return; } - const interval = setInterval(() => { - if (!isMountedSV.value) { - return; - } - - const elapsedSeconds = Recorder.getCurrentDuration(); + const refreshDuration = () => + setDurationString(formatDuration(Recorder.getCurrentDuration())); - const minutes = Math.floor((elapsedSeconds % 3600) / 60) - .toString() - .padStart(2, '0'); - const seconds = Math.floor(elapsedSeconds % 60) - .toString() - .padStart(2, '0'); - const milliseconds = Math.floor((elapsedSeconds % 1) * 1000) - .toString() - .padStart(3, '0'); - - durationStringSV.value = `${minutes}:${seconds}:${milliseconds}`; - }, 100); + // Also refresh immediately so a paused or resynced screen shows the real + // duration before the first interval tick. + refreshDuration(); + const interval = setInterval(refreshDuration, 100); return () => { - isMountedSV.value = false; clearInterval(interval); }; - }, [state, durationStringSV, isMountedSV]); - - const animatedText = useAnimatedProps(() => { - return { - text: durationStringSV.value, - defaultValue: '00:00:000', - }; - }); + }, [state]); - return ( - - ); + return {durationString}; }; export default RecordingTime; diff --git a/apps/common-app/src/demos/Record/RecordingVisualization.tsx b/apps/common-app/src/demos/Record/RecordingVisualization.tsx index 747026742..cc07718fa 100644 --- a/apps/common-app/src/demos/Record/RecordingVisualization.tsx +++ b/apps/common-app/src/demos/Record/RecordingVisualization.tsx @@ -22,7 +22,6 @@ import { withTiming, } from 'react-native-reanimated'; -import { Spacer } from '../../components'; import { audioRecorder as Recorder } from '../../singletons'; import constants from './constants'; import TimeStream from './TimeStream'; @@ -32,18 +31,10 @@ const { width: windowWidth } = Dimensions.get('window'); const defaultNumBars = Math.floor(windowWidth / constants.barStep); -const historyNumBars = Math.floor( - windowWidth / (constants.historyBarWidth + constants.historyBarGap) -); - function getInitialWaveform() { return new Array(defaultNumBars * 2).fill(-1); } -function getInitialHistory() { - return new Array(historyNumBars * 10).fill(-1); -} - interface RecordingVisualizationProps { state: RecordingState; } @@ -57,15 +48,6 @@ interface DrawDefaultWaveformParams { numBars: number; } -interface DrawHistoryWaveformParams { - normalized: number; - lifetimeCanvasHeight: number; - history: number[]; - historyHead: SharedValue; - durationMS: SharedValue; - historyMidpointMS: SharedValue; -} - function drawDefaultWaveform(params: DrawDefaultWaveformParams) { 'worklet'; const { normalized, canvasHeight, barHeights, translateX, lastIndex, numBars } = @@ -108,63 +90,23 @@ function drawDefaultWaveform(params: DrawDefaultWaveformParams) { return barHeights; } -function drawHistoryWaveform(params: DrawHistoryWaveformParams) { - 'worklet'; - - const { - history, - normalized, - lifetimeCanvasHeight, - historyHead, - durationMS, - historyMidpointMS, - } = params; - - if (lifetimeCanvasHeight <= 0) { - return history; - } - - const value = normalized * lifetimeCanvasHeight * 0.8; - history[historyHead.value] = value; - historyHead.value += 1; - - // downsample if needed - if (historyHead.value >= history.length) { - const halfLength = history.length / 2; - - for (let i = 0; i < halfLength; i++) { - history[i] = Math.max(history[2 * i], history[2 * i + 1]); - } - - historyHead.value = halfLength; - historyMidpointMS.value = durationMS.value; - } - - return history; -} - const RecordingVisualization: React.FC = ({ state, }) => { const canvasRef = useCanvasRef(); - const lifetimeCanvasRef = useCanvasRef(); const { size } = useCanvasSize(canvasRef); - const { size: lifetimeSize } = useCanvasSize(lifetimeCanvasRef); const barHeights = useSharedValue(getInitialWaveform()); - const history = useSharedValue(getInitialHistory()); - const historyHead = useSharedValue(0); - const historyMidpointMS = useSharedValue(0); - const historyRenderer = useSharedValue( - new Array(historyNumBars).fill(-1) - ); - const translateX = useSharedValue(0); const lastIndex = useSharedValue(-1); - const durationMS = useSharedValue(0); + // The worklet only accumulates duration from buffers it sees while this component + // is mounted; when the screen re-attaches to an already-running recording, start + // from the recorder's real elapsed time. Seeding here (not in an effect) matters: + // TimeStream's children position their ticks from this value during their own + // mount, which happens before any parent effect could run. + const durationMS = useSharedValue(Recorder.getCurrentDuration() * 1000); const canvasHeightSV = useSharedValue(0); - const lifetimeCanvasHeightSV = useSharedValue(0); const numBarsSV = useSharedValue(0); const stateRef = useRef(state); @@ -205,66 +147,6 @@ const RecordingVisualization: React.FC = ({ return path; }, [size, numBars]); - const historyWaveformPath = useDerivedValue(() => { - const path = Skia.PathBuilder.Make().build(); - const canvasHeight = lifetimeSize.height; - const values = historyRenderer.value; - - if (historyHead.value < historyNumBars) { - // render as it is - for (let i = 0; i < historyHead.value; i++) { - values[i] = history.value[i]; - - if (values[i] < 0) { - continue; - } - - const x = - i * (constants.historyBarWidth + constants.historyBarGap) + - constants.historyBarWidth / 2; - const y1 = (canvasHeight - values[i]) / 2; - const y2 = (canvasHeight + values[i]) / 2; - - path.moveTo(x, y1); - path.lineTo(x, y2); - } - - return path; - } - - const ratio = historyHead.value / historyNumBars; - - // render rest - for (let i = 0; i < historyNumBars; i++) { - let maxVal = -1; - const startIndex = Math.floor(i * ratio); - const endIndex = Math.floor((i + 1) * ratio); - - for (let j = startIndex; j < endIndex; j++) { - if (history.value[j] > maxVal) { - maxVal = history.value[j]; - } - } - - values[i] = maxVal; - - if (values[i] < 0) { - continue; - } - - const x = - i * (constants.historyBarWidth + constants.historyBarGap) + - constants.historyBarWidth / 2; - const y1 = (canvasHeight - values[i]) / 2; - const y2 = (canvasHeight + values[i]) / 2; - - path.moveTo(x, y1); - path.lineTo(x, y2); - } - - return path; - }, [lifetimeSize]); - useEffect(() => { stateRef.current = state; }, [state]); @@ -272,8 +154,7 @@ const RecordingVisualization: React.FC = ({ useEffect(() => { numBarsSV.value = numBars; canvasHeightSV.value = size.height; - lifetimeCanvasHeightSV.value = lifetimeSize.height; - }, [numBars, size.height, lifetimeSize.height, numBarsSV, canvasHeightSV, lifetimeCanvasHeightSV]); + }, [numBars, size.height, numBarsSV, canvasHeightSV]); useEffect(() => { if (numBars <= 0) { @@ -299,7 +180,6 @@ const RecordingVisualization: React.FC = ({ 'worklet'; const canvasHeight = canvasHeightSV.value; - const lifetimeCanvasHeight = lifetimeCanvasHeightSV.value; const activeNumBars = numBarsSV.value; if (canvasHeight <= 0 || activeNumBars <= 0) { @@ -335,19 +215,6 @@ const RecordingVisualization: React.FC = ({ numBars: activeNumBars, }) as T; }); - - history.modify((hist: T) => { - 'worklet'; - - return drawHistoryWaveform({ - normalized, - lifetimeCanvasHeight, - history: hist, - historyHead, - durationMS, - historyMidpointMS, - }) as T; - }); }, { domain: 'time-domain', @@ -419,6 +286,13 @@ const RecordingVisualization: React.FC = ({ useEffect(() => { if (state === RecordingState.Recording) { + if (size.width === 0) { + // Canvas not measured yet (mounting straight into an ongoing recording). + // Starting the scroll animation now would pin translateX at 0 and draw the + // waveform off-screen; this effect re-runs once the size arrives. + return; + } + const animationTarget = -size.width; const animationDuration = 1000 * (size.width / constants.pixelsPerSecond); @@ -450,26 +324,10 @@ const RecordingVisualization: React.FC = ({ cancelAnimation(translateX); translateX.value = 0; barHeights.value = Array(numBars).fill(-1); - historyRenderer.value = Array(historyNumBars).fill(-1); - history.value = Array(historyNumBars * 10).fill(-1); - historyHead.value = 0; - historyMidpointMS.value = 0; durationMS.value = 0; lastIndex.value = -1; } - }, [ - state, - size, - translateX, - barHeights, - numBars, - durationMS, - lastIndex, - history, - historyHead, - historyMidpointMS, - historyRenderer, - ]); + }, [state, size, translateX, barHeights, numBars, durationMS, lastIndex]); const transformPath = useDerivedValue(() => [ { @@ -498,20 +356,6 @@ const RecordingVisualization: React.FC = ({ durationMS={durationMS} /> - - - - - - - - ); }; @@ -531,11 +375,4 @@ const styles = StyleSheet.create({ height: 20, marginTop: 8, }, - lifetimeContainer: { - marginTop: 16, - height: 75, - width: '100%', - backgroundColor: 'rgba(0, 0, 0, 0.15)', - flexDirection: 'column', - }, }); diff --git a/apps/common-app/src/demos/Record/TimeStream.tsx b/apps/common-app/src/demos/Record/TimeStream.tsx index 0fd4ff022..4b16925a8 100644 --- a/apps/common-app/src/demos/Record/TimeStream.tsx +++ b/apps/common-app/src/demos/Record/TimeStream.tsx @@ -23,10 +23,12 @@ interface TimeStreamProps { durationMS: SharedValue; } -function generateInitialTimestamps() { +// Seconds around `baseSecond` so the visible window is fully populated even when +// the stream starts mid-recording (screen re-attached to a live recorder). +function generateInitialTimestamps(baseSecond: number) { const timestamps: number[] = []; - for (let i = -5; i < 15; i++) { + for (let i = baseSecond - 5; i < baseSecond + 15; i++) { timestamps.push(i); } @@ -34,14 +36,14 @@ function generateInitialTimestamps() { } const TimeStream: React.FC = ({ isRecording, durationMS }) => { - const [timestamps, setTimestamps] = useState( - generateInitialTimestamps() + const [timestamps, setTimestamps] = useState(() => + generateInitialTimestamps(Math.floor(durationMS.value / 1000)) ); - const intervalRef = useRef(null); + const intervalRef = useRef | null>(null); useEffect(() => { if (isRecording) { - setTimestamps(generateInitialTimestamps()); + setTimestamps(generateInitialTimestamps(Math.floor(durationMS.value / 1000))); intervalRef.current = setInterval(() => { const elapsedSeconds = durationMS.value / 1000; diff --git a/apps/common-app/src/demos/Record/constants.tsx b/apps/common-app/src/demos/Record/constants.tsx index 4bd332c91..ba3b26063 100644 --- a/apps/common-app/src/demos/Record/constants.tsx +++ b/apps/common-app/src/demos/Record/constants.tsx @@ -9,8 +9,6 @@ const constants = { barGap: 2, minDb: -40, maxDb: 0, - historyBarWidth: 2, - historyBarGap: 2, get barStep() { return this.barWidth + this.barGap; }, diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 5793052e0..9977821ac 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -135,6 +135,21 @@ For the recording to actually survive, all of the following must hold: 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`](#audiorecorderfileoptions) and [`rotateIntervalBytes`](#audiorecorderfileoptions). ::: +A recording that survives task removal can only be controlled from the notification, so give the user a way out: enable the notification's [stop action](/docs/system/recording-notification-manager#the-stop-action) (`showStopAction: true`), which stops the recording natively even when no JS is reachable. + +When the app is opened again, reconcile the UI with what happened while it was away: + +```tsx +if (AudioRecorder.isRecordingOngoing()) { + // the recording is still running — re-attach the UI to it +} else { + const info = AudioRecorder.takeLastRecordingResult(); + if (info) { + // the recording was stopped from the notification; info.paths holds the files + } +} +``` + ## Examples @@ -504,12 +519,43 @@ Returns the current recording duration when file output is enabled. const duration = audioRecorder.getCurrentDuration(); ``` +### `isRecordingOngoing` + +**Static.** Returns `true` while any recording session is ongoing (recording or paused), regardless of which `AudioRecorder` instance started it. Use it after an app relaunch to detect a recording that [outlived the app UI](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed). + +#### Returns `boolean`. + +```tsx +if (AudioRecorder.isRecordingOngoing()) { + // re-attach the UI to the still-running recording +} +``` + +### `takeLastRecordingResult` + +**Static.** Returns the [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) of a recording that was stopped natively — through the [recording notification's stop action](/docs/system/recording-notification-manager#the-stop-action) — or `null` if there is none. Consume-once: the result is cleared on read, so a second call returns `null`. + +Recordings stopped through [`stop`](/docs/inputs/audio-recorder#stop) resolve their promise with the file info instead and never appear here. + +#### Returns [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) or `null`. + +```tsx +const info = AudioRecorder.takeLastRecordingResult(); +if (info) { + // the recording was stopped from the notification; info.paths holds the files +} +``` + ### `enableFileOutput` Configures and enables file output with the given options and stream properties. By default, the recorder writes to the cache directory using a high-quality `M4A` file. For further information, see [`AudioRecorderFileOptions`](#audiorecorderfileoptions). +:::caution +Calling `enableFileOutput` while a recording is ongoing replaces the file writer: output continues into a new file and [`getCurrentDuration`](#getcurrentduration) resets. When re-mounting a screen that may be resyncing with a still-running recording, guard the call with [`isRecordingOngoing`](#isrecordingongoing). +::: + | Parameter | Type | Description | | :---: | :---: | :---- | | `options` | [`AudioRecorderFileOptions`](#audiorecorderfileoptions) | File output configuration. | diff --git a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index 33068db7d..14465785e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,8 @@ class AudioAPIModuleInstaller { auto createAudioBuffer = getCreateAudioBufferFunction(jsiRuntime); auto createAudioDecoder = getCreateAudioDecoderFunction(jsiRuntime, jsCallInvoker); auto createAudioFileUtils = getCreateAudioFileUtilsFunction(jsiRuntime, jsCallInvoker); + auto isRecordingOngoing = getIsRecordingOngoingFunction(jsiRuntime); + auto takeLastRecordingResult = getTakeLastRecordingResultFunction(jsiRuntime); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioContext", createAudioContext); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioRecorder", createAudioRecorder); @@ -46,6 +49,9 @@ class AudioAPIModuleInstaller { jsiRuntime->global().setProperty(*jsiRuntime, "createAudioBuffer", createAudioBuffer); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioDecoder", createAudioDecoder); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioFileUtils", createAudioFileUtils); + jsiRuntime->global().setProperty(*jsiRuntime, "isRecordingOngoing", isRecordingOngoing); + jsiRuntime->global().setProperty( + *jsiRuntime, "takeLastRecordingResult", takeLastRecordingResult); auto audioEventHandlerRegistryHostObject = std::make_shared(audioEventHandlerRegistry); @@ -132,6 +138,43 @@ class AudioAPIModuleInstaller { }); } + static jsi::Function getIsRecordingOngoingFunction(jsi::Runtime *jsiRuntime) { + return jsi::Function::createFromHostFunction( + *jsiRuntime, + jsi::PropNameID::forAscii(*jsiRuntime, "isRecordingOngoing"), + 0, + [](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *args, size_t count) + -> jsi::Value { + return jsi::Value(ActiveRecorderHandle::global().isRecordingOngoing()); + }); + } + + static jsi::Function getTakeLastRecordingResultFunction(jsi::Runtime *jsiRuntime) { + return jsi::Function::createFromHostFunction( + *jsiRuntime, + jsi::PropNameID::forAscii(*jsiRuntime, "takeLastRecordingResult"), + 0, + [](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *args, size_t count) + -> jsi::Value { + auto result = ActiveRecorderHandle::global().takeLastRecordingResult(); + if (!result.has_value()) { + return jsi::Value::null(); + } + + auto jsResult = jsi::Object(runtime); + auto pathsArray = jsi::Array(runtime, result->paths.size()); + for (size_t i = 0; i < result->paths.size(); ++i) { + pathsArray.setValueAtIndex( + runtime, i, jsi::String::createFromUtf8(runtime, result->paths[i])); + } + jsResult.setProperty(runtime, "paths", pathsArray); + jsResult.setProperty(runtime, "size", result->size); + jsResult.setProperty(runtime, "duration", result->duration); + + return jsi::Value(std::move(jsResult)); + }); + } + static jsi::Function getCreateAudioDecoderFunction( jsi::Runtime *jsiRuntime, const std::shared_ptr &jsCallInvoker) { diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index 5bd2be419..52553662a 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -7,7 +7,7 @@ import type { IAudioBuffer, IOfflineAudioContext, } from '../jsi-interfaces'; -import type { AudioRecorderOptions } from '../types'; +import type { AudioRecorderOptions, FileInfo } from '../types'; /* eslint-disable no-var */ declare global { @@ -20,6 +20,10 @@ declare global { var createAudioRecorder: (options: AudioRecorderOptions) => IAudioRecorder; + var isRecordingOngoing: () => boolean; + + var takeLastRecordingResult: () => FileInfo | null; + var createAudioBuffer: ( numberOfChannels: number, length: number, diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index f1cd377f1..ef5c6bca4 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -56,6 +56,27 @@ export default class AudioRecorder { this.recorder = globalThis.createAudioRecorder(options ?? {}); } + /** + * Checks whether any recording session is ongoing (recording or paused), + * regardless of which `AudioRecorder` instance started it. Use it after an + * app relaunch to detect a recording that outlived the UI (Android foreground + * service with `stopWithTask: false`). + */ + static isRecordingOngoing(): boolean { + return globalThis.isRecordingOngoing?.() ?? false; + } + + /** + * Returns the file info of a recording that was stopped natively (e.g. via + * the recording notification stop action), or `null` if there is none. + * Consume-once: the result is cleared on read, so a second call returns + * `null`. Recordings stopped through {@link stop} resolve their promise with + * the file info instead and never appear here. + */ + static takeLastRecordingResult(): FileInfo | null { + return globalThis.takeLastRecordingResult?.() ?? null; + } + /** * Enables writing recorded audio to a file using the provided options. * diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index 5d596e392..c18a3c590 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -852,6 +852,8 @@ class OfflineAudioContextMock extends BaseAudioContextMock { } class AudioRecorderMock { + private static lastCreated: AudioRecorderMock | null = null; + private _isRecording: boolean = false; private _isPaused: boolean = false; private _currentDuration: number = 0; @@ -862,7 +864,18 @@ class AudioRecorderMock { private onErrorSubscription: MockEventSubscription | null = null; // Options only configure the native capture chain, so the mock ignores them. - constructor(_options?: AudioRecorderOptions) {} + constructor(_options?: AudioRecorderOptions) { + AudioRecorderMock.lastCreated = this; + } + + static isRecordingOngoing(): boolean { + const recorder = AudioRecorderMock.lastCreated; + return recorder != null && (recorder._isRecording || recorder._isPaused); + } + + static takeLastRecordingResult(): FileInfo | null { + return null; + } enableFileOutput( options?: AudioRecorderFileOptions diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index 166a91b3d..5a6507c9d 100644 --- a/packages/react-native-audio-api/tests/mock.test.ts +++ b/packages/react-native-audio-api/tests/mock.test.ts @@ -253,6 +253,23 @@ describe('React Native Audio API Mocks', () => { expect(recorder.isRecording()).toBe(false); }); + it('should report an ongoing recording through the static probe', async () => { + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(false); + + await recorder.start(); + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(true); + + recorder.pause(); + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(true); + + await recorder.stop(); + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(false); + }); + + it('should expose the consume-once native stop result as null', () => { + expect(MockAPI.AudioRecorder.takeLastRecordingResult()).toBeNull(); + }); + it('should support RecorderAdapterNode connection', () => { const context = new MockAPI.AudioContext(); const adapter = context.createRecorderAdapter(); From 4818d584de47e8c11d1fb87edb4100b5e50bfeec Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 13:46:20 +0200 Subject: [PATCH 8/9] fix: ci --- packages/audiodocs/CLAUDE.md | 7 +++++++ packages/audiodocs/docs/inputs/audio-recorder.mdx | 12 ++++++------ .../docs/system/recording-notification-manager.mdx | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/audiodocs/CLAUDE.md b/packages/audiodocs/CLAUDE.md index fee64e75e..0eb4be6c5 100644 --- a/packages/audiodocs/CLAUDE.md +++ b/packages/audiodocs/CLAUDE.md @@ -97,6 +97,13 @@ Docusaurus collects link targets from heading ids. A hand-rolled `` +becomes `#takelastrecordingresult-`, with a trailing hyphen. Pin the anchor explicitly instead: + +```mdx +### `takeLastRecordingResult` {#takelastrecordingresult} +``` + ## Sidebar / Navigation Sidebar is **fully autogenerated** from the folder structure — no edits to `sidebars.js` needed. diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 9977821ac..07c95dafa 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -135,7 +135,7 @@ For the recording to actually survive, all of the following must hold: 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`](#audiorecorderfileoptions) and [`rotateIntervalBytes`](#audiorecorderfileoptions). ::: -A recording that survives task removal can only be controlled from the notification, so give the user a way out: enable the notification's [stop action](/docs/system/recording-notification-manager#the-stop-action) (`showStopAction: true`), which stops the recording natively even when no JS is reachable. +A recording that survives task removal can only be controlled from the notification, so give the user a way out: enable the notification's [stop action](../system/recording-notification-manager#native-action-handling) (`showStopAction: true`), which stops the recording natively even when no JS is reachable. When the app is opened again, reconcile the UI with what happened while it was away: @@ -521,7 +521,7 @@ const duration = audioRecorder.getCurrentDuration(); ### `isRecordingOngoing` -**Static.** Returns `true` while any recording session is ongoing (recording or paused), regardless of which `AudioRecorder` instance started it. Use it after an app relaunch to detect a recording that [outlived the app UI](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed). +**Static.** Returns `true` while any recording session is ongoing (recording or paused), regardless of which `AudioRecorder` instance started it. Use it after an app relaunch to detect a recording that [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed). #### Returns `boolean`. @@ -531,13 +531,13 @@ if (AudioRecorder.isRecordingOngoing()) { } ``` -### `takeLastRecordingResult` +### `takeLastRecordingResult` {#takelastrecordingresult} -**Static.** Returns the [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) of a recording that was stopped natively — through the [recording notification's stop action](/docs/system/recording-notification-manager#the-stop-action) — or `null` if there is none. Consume-once: the result is cleared on read, so a second call returns `null`. +**Static.** Returns the [`FileInfo`](#fileinfo) of a recording that was stopped natively — through the [recording notification's stop action](../system/recording-notification-manager.mdx#native-action-handling) — or `null` if there is none. Consume-once: the result is cleared on read, so a second call returns `null`. -Recordings stopped through [`stop`](/docs/inputs/audio-recorder#stop) resolve their promise with the file info instead and never appear here. +Recordings stopped through [`stop`](#stop) resolve their promise with the file info instead and never appear here. -#### Returns [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) or `null`. +#### Returns [`FileInfo`](#fileinfo) or `null`. ```tsx const info = AudioRecorder.takeLastRecordingResult(); diff --git a/packages/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx index 0a02ccf72..9dd1e4e53 100644 --- a/packages/audiodocs/docs/system/recording-notification-manager.mdx +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -50,7 +50,7 @@ RecordingNotificationManager.hide(); 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](../inputs/audio-recorder.mdx#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()`), 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. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.takeLastRecordingResult()`](../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 From 4b82edab1834da91e7e33377ec9147be52737460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20S=C4=99k?= Date: Thu, 27 Aug 2026 22:49:45 +0200 Subject: [PATCH 9/9] fix: cr recommendations and other --- apps/common-app/src/demos/Record/Record.tsx | 19 ++--- .../audiodocs/docs/inputs/audio-recorder.mdx | 8 +- .../audiodocs/docs/other/audio-api-plugin.mdx | 4 +- .../system/recording-notification-manager.mdx | 2 +- .../android/core/AndroidAudioRecorder.cpp | 7 ++ .../android/system/NativeRecorderControl.cpp | 4 +- .../android/system/NativeRecorderControl.hpp | 2 +- .../system/CentralizedForegroundService.kt | 76 +++++++++-------- .../audioapi/system/NativeRecorderControl.kt | 8 +- .../notification/NotificationRegistry.kt | 51 ++++++++---- .../notification/RecordingNotification.kt | 83 +++++++------------ .../RecordingNotificationReceiver.kt | 39 ++++++--- .../state/RecordingNotificationState.kt | 2 +- .../core/inputs/ActiveRecorderHandle.cpp | 22 ++--- .../core/inputs/ActiveRecorderHandle.h | 12 +-- .../core/utils/AudioRecorderCallback.h | 4 +- .../core/inputs/ActiveRecorderHandleTest.cpp | 57 +++++++++++-- .../src/AudioAPIModule/globals.d.ts | 4 +- .../src/core/AudioRecorder.ts | 22 +++-- .../src/plugin/withAudioAPI.ts | 5 +- .../react-native-audio-api/tests/mock.test.ts | 2 +- 21 files changed, 251 insertions(+), 182 deletions(-) diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 79cac60b8..b607f0f8f 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -4,8 +4,6 @@ import { AudioBufferSourceNode, AudioManager, AudioRecorder, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- used by the commented-out concat flow above - concatAudioFiles, FileFormat, RecordingNotificationManager, } from 'react-native-audio-api'; @@ -65,7 +63,6 @@ const Record: FC = () => { smallIconResourceName: 'logo', color: 0xff6200, showStopAction: true, - stopIconResourceName: 'stop', deepLinkUri: 'audioapi-example://record', usesChronometer: true, }); @@ -136,11 +133,7 @@ const Record: FC = () => { async (paths: string[]) => { setState(RecordingState.Loading); - // const outputPath = paths[0].replace(/[^/]+$/, 'recording.wav'); - - // const finalPath = await concatAudioFiles(paths, outputPath); - const finalPath = paths[0]; - const audioBuffer = await audioContext.decodeAudioData(finalPath); + const audioBuffer = await audioContext.decodeAudioData(paths[0]); setRecordedBuffer(audioBuffer); setState(RecordingState.ReadyToPlay); @@ -267,13 +260,15 @@ const Record: FC = () => { useEffect(() => { (async () => { - const recordingPermissionStatus = await AudioManager.checkRecordingPermissions(); + const recordingPermissionStatus = + await AudioManager.checkRecordingPermissions(); if (recordingPermissionStatus === 'Granted') { setHasPermissions(true); } - const notificationPermissionStatus = await AudioManager.checkNotificationPermissions(); + const notificationPermissionStatus = + await AudioManager.checkNotificationPermissions(); if (notificationPermissionStatus !== 'Granted') { const result = await AudioManager.requestNotificationPermissions(); if (result !== 'Granted') { @@ -340,6 +335,10 @@ const Record: FC = () => { // The recording and its notification intentionally stay alive when leaving this // screen; they can be stopped from the notification or after coming back. stopPlayback(); + + if (!AudioRecorder.isRecordingOngoing()) { + AudioManager.setAudioSessionActivity(false); + } }; }, [stopPlayback]); diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 07c95dafa..c5b93b5aa 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -93,13 +93,13 @@ Additionally to be able to record audio while application is in the background, -### Keeping the recording alive when the app is closed +### Keeping the recording alive when the app is closed {#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](../other/audio-api-plugin#androidfsstopwithtask) to `false`: + Set the `androidFSStopWithTask` option of the [expo plugin](../other/audio-api-plugin.mdx#androidfsstopwithtask) to `false`: ```json { @@ -135,7 +135,7 @@ For the recording to actually survive, all of the following must hold: 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`](#audiorecorderfileoptions) and [`rotateIntervalBytes`](#audiorecorderfileoptions). ::: -A recording that survives task removal can only be controlled from the notification, so give the user a way out: enable the notification's [stop action](../system/recording-notification-manager#native-action-handling) (`showStopAction: true`), which stops the recording natively even when no JS is reachable. +A recording that survives task removal can only be controlled from the notification, so give the user a way out: enable the notification's [stop action](../system/recording-notification-manager.mdx#native-action-handling) (`showStopAction: true`), which stops the recording natively even when no JS is reachable. When the app is opened again, reconcile the UI with what happened while it was away: @@ -521,7 +521,7 @@ const duration = audioRecorder.getCurrentDuration(); ### `isRecordingOngoing` -**Static.** Returns `true` while any recording session is ongoing (recording or paused), regardless of which `AudioRecorder` instance started it. Use it after an app relaunch to detect a recording that [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed). +**Static.** Returns `true` while a recording session is ongoing (recording or paused). Native source of truth that needs no reference to the recorder instance, so use it from a remounted screen — e.g. after reopening an app whose recording [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed) — to seed the UI state. It reflects the most recently created `AudioRecorder`; constructing another instance mid-recording displaces the probed one. #### Returns `boolean`. diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index 025ff6ce3..de6754127 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -18,7 +18,7 @@ interface Options { androidPermissions: string[]; androidForegroundService: boolean; androidFSTypes: string[]; - androidFSStopWithTask?: boolean; + androidFSStopWithTask: boolean; } ``` @@ -144,5 +144,5 @@ Controls the `android:stopWithTask` attribute of the Foreground Service injected 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](../inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. +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](../inputs/audio-recorder.mdx#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. ::: diff --git a/packages/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx index 9dd1e4e53..14d5b594b 100644 --- a/packages/audiodocs/docs/system/recording-notification-manager.mdx +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -50,7 +50,7 @@ RecordingNotificationManager.hide(); 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](../inputs/audio-recorder.mdx#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()`](../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. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.takeLastRecordingResult()`](../inputs/audio-recorder.mdx#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 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 4b2253767..7ef541878 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 @@ -595,6 +595,13 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re cleanup(); + // An idle session has nothing to restore — this covers a disconnect delivered + // late, after stop() already finished — and reopening here would leave a fresh, + // never-started mic stream held while idle. + if (stateBeforeTeardown == RecorderState::Idle) { + return; + } + auto streamResult = openAudioStream(); if (!streamResult.is_ok()) { 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 index 7a6b25012..7baf9c076 100644 --- 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 @@ -9,7 +9,7 @@ void NativeRecorderControl::registerNatives() { makeNativeMethod("stopActiveRecording", NativeRecorderControl::stopActiveRecording), makeNativeMethod("pauseActiveRecording", NativeRecorderControl::pauseActiveRecording), makeNativeMethod("resumeActiveRecording", NativeRecorderControl::resumeActiveRecording), - makeNativeMethod("isRecordingActive", NativeRecorderControl::isRecordingActive), + makeNativeMethod("isRecordingOngoing", NativeRecorderControl::isRecordingOngoing), }); } @@ -25,7 +25,7 @@ jboolean NativeRecorderControl::resumeActiveRecording(jni::alias_ref(ActiveRecorderHandle::global().resumeActiveRecording()); } -jboolean NativeRecorderControl::isRecordingActive(jni::alias_ref /*clazz*/) { +jboolean NativeRecorderControl::isRecordingOngoing(jni::alias_ref /*clazz*/) { return static_cast(ActiveRecorderHandle::global().isRecordingOngoing()); } 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 index 92a378dd9..fc80c7ef6 100644 --- 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 @@ -18,7 +18,7 @@ class NativeRecorderControl : public jni::JavaClass { static jboolean stopActiveRecording(jni::alias_ref); static jboolean pauseActiveRecording(jni::alias_ref); static jboolean resumeActiveRecording(jni::alias_ref); - static jboolean isRecordingActive(jni::alias_ref); + static jboolean isRecordingOngoing(jni::alias_ref); }; } // namespace audioapi 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 059c75351..8d140841a 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 @@ -61,7 +61,7 @@ class CentralizedForegroundService : Service() { private fun startForegroundWithNotification() { try { - createNotificationChannelIfNeeded() + createLowImportanceChannelIfNeeded(CHANNEL_ID, "Audio Service", "Background audio processing") // Get the first available notification val existingNotification = findExistingNotification() @@ -83,7 +83,11 @@ class CentralizedForegroundService : Service() { } private fun startForegroundWithPlaceholderAndStop() { - createPlaceholderNotificationChannelIfNeeded() + createLowImportanceChannelIfNeeded( + PLACEHOLDER_CHANNEL_ID, + "Audio Service Placeholder", + "Short-lived notification shown while the audio service shuts down", + ) val placeholderNotification = NotificationCompat @@ -93,9 +97,15 @@ class CentralizedForegroundService : Service() { .setPriority(NotificationCompat.PRIORITY_LOW) .build() - startForegroundCompat(PLACEHOLDER_NOTIFICATION_ID, placeholderNotification) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + try { + startForegroundCompat(PLACEHOLDER_NOTIFICATION_ID, placeholderNotification) + } finally { + // The service must exit even when startForeground throws (e.g. API 34+ + // ForegroundServiceStartNotAllowedException) — otherwise the system kills the + // process with ForegroundServiceDidNotStartInTimeException. + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } } private fun startForegroundCompat( @@ -166,43 +176,31 @@ class CentralizedForegroundService : Service() { return null } - private fun createNotificationChannelIfNeeded() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) { - val channel = - NotificationChannel( - CHANNEL_ID, - "Audio Service", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Background audio processing" - setShowBadge(false) - lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - } - notificationManager.createNotificationChannel(channel) - } + private fun createLowImportanceChannelIfNeeded( + id: String, + name: String, + channelDescription: String, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return } - } - 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) - } + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationManager.getNotificationChannel(id) != null) { + return } + + val channel = + NotificationChannel( + id, + name, + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = channelDescription + setShowBadge(false) + lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + } + notificationManager.createNotificationChannel(channel) } override fun onDestroy() { 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 index a54727a54..9be556d3e 100644 --- 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 @@ -17,14 +17,18 @@ object NativeRecorderControl { * * @return true if a recording was stopped by this call. */ + @JvmStatic external fun stopActiveRecording(): Boolean /** Pauses an actively recording session. @return true if this call paused it. */ + @JvmStatic external fun pauseActiveRecording(): Boolean /** Resumes a paused session. @return true if this call resumed it. */ + @JvmStatic external fun resumeActiveRecording(): Boolean - /** Non-blocking check whether a recording session (recording or paused) is active. */ - external fun isRecordingActive(): Boolean + /** Non-blocking check whether a recording session (recording or paused) is ongoing. */ + @JvmStatic + external fun isRecordingOngoing(): 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 087c71ded..31ba5857e 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 @@ -8,10 +9,16 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.swmansion.audioapi.system.ForegroundServiceManager import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap /** * Central notification registry that manages multiple notification instances. * Automatically handles foreground service lifecycle based on active notifications. + * + * Public methods are called from the JS thread and, for native-initiated notification + * actions, from [RecordingNotificationReceiver]'s executor — hence `@Synchronized`. The + * registry monitor also serializes all mutation of the notification instances' state + * (`show`/`hide`/`rebuildWithPausedState` only run inside it). */ class NotificationRegistry( private val reactContext: WeakReference, @@ -20,8 +27,9 @@ class NotificationRegistry( companion object { private const val TAG = "NotificationRegistry" - // Store last built notifications for foreground service access - private val builtNotifications = mutableMapOf() + // Store last built notifications for foreground service access. Concurrent because + // CentralizedForegroundService reads it on its main thread, outside the registry monitor. + private val builtNotifications = ConcurrentHashMap() fun getBuiltNotification(notificationId: Int): Notification? = builtNotifications[notificationId] } @@ -39,6 +47,7 @@ class NotificationRegistry( * @param type The type of notification (only used for first creation) * @param options Configuration options from JavaScript */ + @Synchronized @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) fun showNotification( key: String, @@ -81,6 +90,7 @@ class NotificationRegistry( * * @param key The unique identifier of the notification */ + @Synchronized fun hideNotification(key: String) { val notification = notifications[key] if (notification == null) { @@ -88,20 +98,23 @@ class NotificationRegistry( return } - try { - // Only hide if currently active - if (activeNotifications.getOrDefault(key, false)) { - cancelNotification(notification.getNotificationId()) - notification.hide() - activeNotifications[key] = false - - // Unsubscribe from foreground service - ForegroundServiceManager.unsubscribe(notification) + // Only hide if currently active + if (!activeNotifications.getOrDefault(key, false)) { + return + } - Log.d(TAG, "Hiding notification: $key (unsubscribed from foreground service)") - } + try { + cancelNotification(notification.getNotificationId()) + notification.hide() } catch (e: Exception) { Log.e(TAG, "Error hiding notification $key: ${e.message}", e) + } finally { + // Even when hide() throws (e.g. the React context was already released), the + // registry must record the notification as inactive and let the foreground + // service unwind — otherwise it runs forever. + activeNotifications[key] = false + ForegroundServiceManager.unsubscribe(notification) + Log.d(TAG, "Hiding notification: $key (unsubscribed from foreground service)") } } @@ -112,6 +125,7 @@ class NotificationRegistry( * * @param id The Android notification ID, e.g. [RecordingNotification.ID] */ + @Synchronized fun hideNotification(id: Int) { notifications.entries .firstOrNull { it.value.getNotificationId() == id } @@ -124,6 +138,8 @@ class NotificationRegistry( * is unreachable. No-op unless the recording notification is currently visible — * which also means the POST_NOTIFICATIONS permission was already granted. */ + @Synchronized + @SuppressLint("MissingPermission") fun updateRecordingNotificationPausedState(paused: Boolean) { val entry = notifications.entries.firstOrNull { @@ -180,6 +196,7 @@ class NotificationRegistry( * * @param key The unique identifier of the notification */ + @Synchronized fun destroyNotification(key: String) { hideNotification(key) notifications.remove(key) @@ -190,16 +207,19 @@ class NotificationRegistry( /** * Check if a notification is currently active. */ + @Synchronized fun isNotificationActive(key: String): Boolean = activeNotifications.getOrDefault(key, false) /** * Get all registered notification keys. */ + @Synchronized fun getRegisteredKeys(): Set = notifications.keys.toSet() /** * Cleanup all notifications. */ + @Synchronized fun cleanup() { notifications.keys.toList().forEach { key -> hideNotification(key) @@ -233,9 +253,10 @@ class NotificationRegistry( } private fun cancelNotification(id: Int) { + // Drop the stored notification first so the foreground service can no longer pick + // it up, even when the released React context prevents the system-side cancel. + builtNotifications.remove(id) val context = reactContext.get() ?: return NotificationManagerCompat.from(context).cancel(id) - // Clean up stored notification - builtNotifications.remove(id) } } 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 74967a8bf..335a6e3e4 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 @@ -16,7 +16,6 @@ import androidx.core.content.ContextCompat import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.swmansion.audioapi.AudioAPIModule -import com.swmansion.audioapi.R import com.swmansion.audioapi.system.notification.state.RecordingNotificationState import java.lang.ref.WeakReference @@ -221,62 +220,36 @@ class RecordingNotification( } private fun parseMapFromRN(options: ReadableMap?) { - state.title = if (options?.hasKey("title") == true) options.getString("title") else state.title ?: "Recording Audio" - state.contentText = - if (options?.hasKey("contentText") == true) { - options.getString("contentText") - } else { - state.contentText ?: "Audio recording is in progress/paused" - } - state.smallIconResourceName = - if (options?.hasKey("smallIconResourceName") == true) { - options.getString("smallIconResourceName") - } else { - state.smallIconResourceName - } - state.largeIconResourceName = - if (options?.hasKey("largeIconResourceName") == true) { - options.getString("largeIconResourceName") - } else { - state.largeIconResourceName - } - 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.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 + state.title = options.stringOr("title", state.title ?: "Recording Audio") + state.contentText = options.stringOr("contentText", state.contentText ?: "Audio recording is in progress/paused") + state.smallIconResourceName = options.stringOr("smallIconResourceName", state.smallIconResourceName) + state.largeIconResourceName = options.stringOr("largeIconResourceName", state.largeIconResourceName) + state.backgroundColor = options.intOr("color", state.backgroundColor) + state.showStopAction = options.boolOr("showStopAction", state.showStopAction) + state.pauseActionTitle = options.stringOr("pauseActionTitle", state.pauseActionTitle) + state.resumeActionTitle = options.stringOr("resumeActionTitle", state.resumeActionTitle) + state.stopActionTitle = options.stringOr("stopActionTitle", state.stopActionTitle) + state.deepLinkUri = options.stringOr("deepLinkUri", state.deepLinkUri) + state.usesChronometer = options.boolOr("usesChronometer", state.usesChronometer) + // Deliberately not sticky — see the [RecordingNotificationState] KDoc. + state.paused = options.boolOr("paused", false) } + private fun ReadableMap?.stringOr( + key: String, + fallback: String?, + ): String? = if (this?.hasKey(key) == true) getString(key) else fallback + + private fun ReadableMap?.boolOr( + key: String, + fallback: Boolean, + ): Boolean = if (this?.hasKey(key) == true) getBoolean(key) else fallback + + private fun ReadableMap?.intOr( + key: String, + fallback: Int?, + ): Int? = if (this?.hasKey(key) == true) getInt(key) else fallback + private fun createNotificationChannel(context: ReactApplicationContext) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = 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 188022a8b..f90f0a503 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 @@ -56,15 +56,21 @@ class RecordingNotificationReceiver( 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()) + val toggled = + if (paused) { + NativeRecorderControl.pauseActiveRecording() + } else { + NativeRecorderControl.resumeActiveRecording() + } + // `false` means no recording was in a state this action applies to, so neither + // the notification look nor JS may flip. + if (toggled) { + MediaSessionManager.setRecordingNotificationPaused(paused) + dispatchEventToJs( + if (paused) AudioEvent.RECORDING_NOTIFICATION_PAUSE else AudioEvent.RECORDING_NOTIFICATION_RESUME, + ) } - MediaSessionManager.setRecordingNotificationPaused(paused) - } catch (e: UnsatisfiedLinkError) { + } catch (e: LinkageError) { 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) @@ -82,9 +88,12 @@ class RecordingNotificationReceiver( controlExecutor.execute { try { NativeRecorderControl.stopActiveRecording() - module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_STOP.ordinal, mapOf()) + // The notification and foreground service unwind before JS is notified: the + // recording is already over, so even a throwing JS dispatch must not leave a + // stuck "recording" notification with a running microphone-typed service. MediaSessionManager.hideRecordingNotification() - } catch (e: UnsatisfiedLinkError) { + dispatchEventToJs(AudioEvent.RECORDING_NOTIFICATION_STOP) + } catch (e: LinkageError) { 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) @@ -93,4 +102,14 @@ class RecordingNotificationReceiver( } } } + + /** Syncing a live JS runtime is best-effort — in the task-removed scenario the JNI + * dispatch can throw, and that must not undo the native work that already completed. */ + private fun dispatchEventToJs(event: AudioEvent) { + try { + module.invokeHandlerWithEventNameAndEventBody(event.ordinal, mapOf()) + } catch (e: Exception) { + Log.e(TAG, "Recording notification action completed natively, but notifying JS failed: ${e.message}", e) + } + } } 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 488d31bb7..ed5f9bb95 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 @@ -7,7 +7,7 @@ import com.swmansion.audioapi.system.notification.RecordingNotificationReceiver * 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( +class RecordingNotificationState( var receiver: RecordingNotificationReceiver? = null, var initialized: Boolean = false, var title: String? = null, 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 index 39ccfe990..a32ddd92a 100644 --- 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 @@ -19,12 +19,15 @@ void ActiveRecorderHandle::setRecorder(const std::shared_ptr &rec } void ActiveRecorderHandle::clearRecorder(const AudioRecorder *recorder) { - std::scoped_lock lock(mutex_); - auto current = recorder_.lock(); - if (current != nullptr && current.get() != recorder) { - return; + std::shared_ptr current; + { + std::scoped_lock lock(mutex_); + current = recorder_.lock(); + if (current != nullptr && current.get() != recorder) { + return; + } + recorder_.reset(); } - recorder_.reset(); } bool ActiveRecorderHandle::isRecordingOngoing() { @@ -72,8 +75,9 @@ bool ActiveRecorderHandle::stopActiveRecording() { return false; } - // stop() blocks on file finalization and the recorder's destructor may call - // clearRecorder() concurrently, so mutex_ must not be held around it. + // stop() blocks for as long as file finalization takes (possibly seconds), so + // mutex_ is released around it to keep isRecordingOngoing(), setRecorder() and + // clearRecorder() (e.g. from ~AudioRecorderHostObject) responsive meanwhile. auto result = recorder->stop(); if (!result.is_ok()) { return false; @@ -90,9 +94,7 @@ bool ActiveRecorderHandle::stopActiveRecording() { std::optional ActiveRecorderHandle::takeLastRecordingResult() { std::scoped_lock lock(mutex_); - auto result = std::move(lastResult_); - lastResult_.reset(); - return result; + return std::exchange(lastResult_, std::nullopt); } } // 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 index b442dc694..956aad452 100644 --- 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 @@ -18,11 +18,13 @@ struct RecordingStopResult { /// @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. +/// The recorder is owned solely by its JS-side host object, but Android's +/// recording-notification actions arrive through static JNI with no React context to +/// walk back to that object — a weak one-slot handle is the minimal bridge that lets +/// them control the live recorder. On top of native notification control it stashes, +/// consume-once, the file info of a recording finalized natively while no JS promise +/// or listener was waiting, and lets a remounted UI seed its state from the native +/// source of truth via isRecordingOngoing(). /// /// Assumes at most one AudioRecorder is alive at a time; setting a new recorder replaces /// the previous one. 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 b4582c8be..02ece11f8 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,12 +42,14 @@ class AudioRecorderCallback { void clearOnErrorCallback() { assignOnErrorCallbackId(0); } + void invokeOnErrorCallback(const std::string &message); + + private: // 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: std::atomic isInitialized_{false}; 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 index 6bce4cd06..18b70d01b 100644 --- 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 @@ -175,18 +175,57 @@ TEST(ActiveRecorderHandleTest, ClearRecorderIgnoresForeignPointer) { EXPECT_FALSE(handle.isRecordingOngoing()); } +// Thread startup skew usually serializes a single two-thread run, so the race +// tests below repeat with a fresh handle/recorder and release both threads at +// once through an atomic start flag to actually hit concurrent interleavings. +constexpr int RACE_TEST_ITERATIONS = 200; + TEST(ActiveRecorderHandleTest, ConcurrentStopsCloseTheFileExactlyOnce) { - ActiveRecorderHandle handle; - auto recorder = std::make_shared(); - handle.setRecorder(recorder); - recorder->start(""); + for (int iteration = 0; iteration < RACE_TEST_ITERATIONS; ++iteration) { + 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(); + std::atomic startFlag{false}; + std::thread nativeStop([&] { + while (!startFlag.load()) {} + handle.stopActiveRecording(); + }); + std::thread jsStop([&] { + while (!startFlag.load()) {} + recorder->stop(); + }); + startFlag.store(true); + nativeStop.join(); + jsStop.join(); + + EXPECT_EQ(recorder->stopCount, 1) << "iteration " << iteration; + } +} - EXPECT_EQ(recorder->stopCount, 1); +TEST(ActiveRecorderHandleTest, ConcurrentClearAndStopNeverCloseTheFileTwice) { + for (int iteration = 0; iteration < RACE_TEST_ITERATIONS; ++iteration) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + + std::atomic startFlag{false}; + std::thread hostObjectClear([&] { + while (!startFlag.load()) {} + handle.clearRecorder(recorder.get()); + }); + std::thread nativeStop([&] { + while (!startFlag.load()) {} + handle.stopActiveRecording(); + }); + startFlag.store(true); + hostObjectClear.join(); + nativeStop.join(); + + EXPECT_LE(recorder->stopCount, 1) << "iteration " << iteration; + } } // NOLINTEND diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index 52553662a..9d33c54a4 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -20,9 +20,9 @@ declare global { var createAudioRecorder: (options: AudioRecorderOptions) => IAudioRecorder; - var isRecordingOngoing: () => boolean; + var isRecordingOngoing: (() => boolean) | undefined; - var takeLastRecordingResult: () => FileInfo | null; + var takeLastRecordingResult: (() => FileInfo | null) | undefined; var createAudioBuffer: ( numberOfChannels: number, diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index ef5c6bca4..67137f2c7 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -57,21 +57,25 @@ export default class AudioRecorder { } /** - * Checks whether any recording session is ongoing (recording or paused), - * regardless of which `AudioRecorder` instance started it. Use it after an - * app relaunch to detect a recording that outlived the UI (Android foreground - * service with `stopWithTask: false`). + * Checks whether a recording session is ongoing (recording or paused). Native + * source of truth that needs no reference to the recorder instance, so a + * remounted screen (e.g. after navigating away and back, or reopening an app + * whose recording kept running under an Android foreground service with + * `stopWithTask: false`) can seed its UI state from it. Reflects the most + * recently created `AudioRecorder` — constructing another instance + * mid-recording displaces the probed one. */ static isRecordingOngoing(): boolean { return globalThis.isRecordingOngoing?.() ?? false; } /** - * Returns the file info of a recording that was stopped natively (e.g. via - * the recording notification stop action), or `null` if there is none. - * Consume-once: the result is cleared on read, so a second call returns - * `null`. Recordings stopped through {@link stop} resolve their promise with - * the file info instead and never appear here. + * Returns the file info of a recording that was stopped natively (via the + * recording notification stop action, which finalizes the files even when no + * JS listener is reachable), or `null` if there is none. Consume-once: the + * result is cleared on read, so a second call returns `null`. Recordings + * stopped through {@link stop} resolve their promise with the file info + * instead and never appear here. */ static takeLastRecordingResult(): FileInfo | null { return globalThis.takeLastRecordingResult?.() ?? null; diff --git a/packages/react-native-audio-api/src/plugin/withAudioAPI.ts b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts index c4c4b5aa1..6b2481224 100644 --- a/packages/react-native-audio-api/src/plugin/withAudioAPI.ts +++ b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts @@ -21,7 +21,7 @@ interface Options { * the app process and any in-progress recording — running (Android calls * onTaskRemoved instead of stopping the service). Defaults to true. */ - androidFSStopWithTask?: boolean; + androidFSStopWithTask: boolean; disableFFmpeg: boolean; disableStaticExternalLibs: boolean; } @@ -86,8 +86,7 @@ const withForegroundService: ConfigPlugin = ( $: { 'android:name': 'com.swmansion.audioapi.system.CentralizedForegroundService', - 'android:stopWithTask': - androidFSStopWithTask === false ? 'false' : 'true', + 'android:stopWithTask': String(androidFSStopWithTask), 'android:foregroundServiceType': SFTypes, }, intentFilter: [], diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index 5a6507c9d..48b8be5f5 100644 --- a/packages/react-native-audio-api/tests/mock.test.ts +++ b/packages/react-native-audio-api/tests/mock.test.ts @@ -266,7 +266,7 @@ describe('React Native Audio API Mocks', () => { expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(false); }); - it('should expose the consume-once native stop result as null', () => { + it('should return null when no native stop occurred', () => { expect(MockAPI.AudioRecorder.takeLastRecordingResult()).toBeNull(); });