diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 86d1c4bd6..ba3050ccf 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -130,6 +130,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de | Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) | | CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread | | Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` | +| Platform code (Kotlin) must reach a C++ object with no JS runtime alive | Process-global handle (`ActiveRecorderHandle` — mutex + `weak_ptr`, registered by the HostObject ctor/dtor) + static-JNI `JavaClass` (`NativeRecorderControl`, no HybridData needed). Blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` — Kotlin threads are already JNI-attached | --- diff --git a/CLAUDE.md b/CLAUDE.md index 4c20d4c2d..f1715d44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ packages/custom-node-generator/ # Code generation tooling - **New Architecture Ready**: Supports both old Bridge and new TurboModules/Fabric - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets +- **Notification-Driven Foreground Service (Android)**: `NotificationRegistry.showNotification` → `ForegroundServiceManager.subscribe` → `CentralizedForegroundService`; service lifetime follows notification visibility, never recorder/player state. The library manifest is empty — consuming apps declare the `` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal +- **JS-Independent Recorder Control (Android)**: the recording notification's stop action must work after task removal, when no JS listener is reachable. `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` registered by `AudioRecorderHostObject`) exposes the live recorder process-globally; Kotlin reaches it through the static-JNI `NativeRecorderControl` object (no HybridData/React context needed — the reverse of the `NativeFileInfo` pattern). Results of a native stop are stashed consume-once in the handle - **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`). ### Native Module Entry Points diff --git a/apps/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/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"> - + +### 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/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx index 571b5c61a..e0633c574 100644 --- a/packages/audiodocs/docs/system/recording-notification-manager.mdx +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -10,7 +10,7 @@ import { # RecordingNotificationManager The `RecordingNotificationManager` provides system integration with [`AudioRecorder`](/docs/inputs/audio-recorder) on Android. -It can send events about pausing and resuming to your application. +It shows a standard notification with pause/resume and (optionally) stop actions, can route notification taps to a specific screen, and sends action events to your application. :::note iOS `RecordingNotificationManager` is not available on iOS. For a recording indicator on the Lock Screen and in the Dynamic Island, use a [Live Activity](https://docs.expo.dev/versions/latest/sdk/widgets/) built with [`expo-widgets`](https://docs.expo.dev/versions/latest/sdk/widgets/). @@ -23,9 +23,10 @@ RecordingNotificationManager.show({ contentText: 'Recording...', paused: false, smallIconResourceName: 'icon_to_display', - pauseIconResourceName: 'pause_icon', - resumeIconResourceName: 'resume_icon', color: 0xff6200, + showStopAction: true, + deepLinkUri: 'myapp://record', + usesChronometer: true, }); const pauseEventListener = RecordingNotificationManager.addEventListener('recordingNotificationPause', () => { @@ -34,19 +35,45 @@ const pauseEventListener = RecordingNotificationManager.addEventListener('record const resumeEventListener = RecordingNotificationManager.addEventListener('recordingNotificationResume', () => { console.log('Notification resume action received'); }); +const stopEventListener = RecordingNotificationManager.addEventListener('recordingNotificationStop', () => { + console.log('Notification stop action received'); +}); pauseEventListener.remove(); resumeEventListener.remove(); +stopEventListener.remove(); RecordingNotificationManager.hide(); ``` +## Native action handling + +All notification actions act on the recorder **natively**, without a JS round-trip. This matters when the recording outlives the app UI (see [keeping the recording alive when the app is closed](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed)) — pause, resume and stop keep working even after the app task has been removed and no JS listener is reachable. + +- **Pause / resume** pause or resume the recorder and flip the notification's action button. The matching event (`recordingNotificationPause` / `recordingNotificationResume`) still fires so a live app can sync its UI — handlers calling `AudioRecorder.pause()` / `resume()` again are harmless, the recorder ignores same-state transitions. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.takeLastRecordingResult()`](/docs/inputs/audio-recorder#takelastrecordingresult)), emits `recordingNotificationStop`, then hides the notification, which also stops the foreground service. Unlike pause/resume, your `recordingNotificationStop` listener should **not** call `AudioRecorder.stop()` — the recording is already stopped. Collect the files with `AudioRecorder.takeLastRecordingResult()` instead. + +## Routing the notification tap + +By default, tapping the notification opens the app's launcher activity. Set `deepLinkUri` to attach a URI to the tap intent; React Native delivers it through [`Linking`](https://reactnative.dev/docs/linking) (`getInitialURL()` on cold start, the `url` event otherwise). With React Navigation, map it to a screen with a [`linking` config](https://reactnavigation.org/docs/deep-linking/): + +```tsx +const linking = { + prefixes: ['myapp://'], + config: { screens: { RecordScreen: 'record' } }, +}; + + +``` + +No `AndroidManifest.xml` changes are needed — the notification uses an explicit launch intent, so the URI does not go through intent filters. (If you also want the same URI to work from a browser or `adb`, declare your own scheme intent filter, e.g. via Expo's `scheme` option.) + ## Methods ### `show` Shows the recording notification with the parameters. -Metadata is saved between calls, so after the initial pass to the show method, you need only call it with elements that are supposed to change. +Metadata is saved between calls, so after the initial pass to the show method, you need only call it with elements that are supposed to change. The only exception is `paused`, which resets to `false` when absent. | Parameter |Type| Description| | :---: | :---: | :---- | @@ -60,8 +87,7 @@ Resource name is a path to resource placed in res/drawable folder. It has to be ::: :::caution -If nothing is displayed, even though your name is correct, try decreasing size of your resource. -Notification can look vastly different on different android devices. +The notification uses the standard Android template, so its exact look varies between devices and Android versions. ::: ### `hide` @@ -97,12 +123,16 @@ 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; // + color?: number; + showStopAction?: boolean; // shows the native stop action, default: false + 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 +147,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..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 @@ -591,6 +591,8 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re return; } + const auto stateBeforeTeardown = state_.load(std::memory_order_acquire); + cleanup(); auto streamResult = openAudioStream(); @@ -610,8 +612,13 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re return; } - mStream_->requestStart(); - state_.store(RecorderState::Recording, std::memory_order_release); + // Restore the interrupted session's state instead of unconditionally recording — + // a paused session must stay paused, or the reopened stream would silently turn + // the microphone back on against an explicit user action. + if (stateBeforeTeardown == RecorderState::Recording) { + mStream_->requestStart(); + } + state_.store(stateBeforeTeardown, std::memory_order_release); } } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp new file mode 100644 index 000000000..7a6b25012 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp @@ -0,0 +1,32 @@ +#include + +#include + +namespace audioapi { + +void NativeRecorderControl::registerNatives() { + javaClassStatic()->registerNatives({ + makeNativeMethod("stopActiveRecording", NativeRecorderControl::stopActiveRecording), + makeNativeMethod("pauseActiveRecording", NativeRecorderControl::pauseActiveRecording), + makeNativeMethod("resumeActiveRecording", NativeRecorderControl::resumeActiveRecording), + makeNativeMethod("isRecordingActive", NativeRecorderControl::isRecordingActive), + }); +} + +jboolean NativeRecorderControl::stopActiveRecording(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().stopActiveRecording()); +} + +jboolean NativeRecorderControl::pauseActiveRecording(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().pauseActiveRecording()); +} + +jboolean NativeRecorderControl::resumeActiveRecording(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().resumeActiveRecording()); +} + +jboolean NativeRecorderControl::isRecordingActive(jni::alias_ref /*clazz*/) { + return static_cast(ActiveRecorderHandle::global().isRecordingOngoing()); +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp new file mode 100644 index 000000000..92a378dd9 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace audioapi { + +using namespace facebook; + +/// @brief JNI statics that let Kotlin reach the active recorder without a React +/// context or JS runtime, e.g. from the recording-notification stop action after +/// the app task was removed. Backed by ActiveRecorderHandle. +class NativeRecorderControl : public jni::JavaClass { + public: + static auto constexpr kJavaDescriptor = "Lcom/swmansion/audioapi/system/NativeRecorderControl;"; + + static void registerNatives(); + + static jboolean stopActiveRecording(jni::alias_ref); + static jboolean pauseActiveRecording(jni::alias_ref); + static jboolean resumeActiveRecording(jni::alias_ref); + static jboolean isRecordingActive(jni::alias_ref); +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt index 72e02d5f0..8ac9cdad2 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt @@ -25,4 +25,5 @@ enum class AudioEvent { POSITION_CHANGED, BUFFER_ENDED, RECORDER_ERROR, + RECORDING_NOTIFICATION_STOP, } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/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/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt index af739a8d0..a3c907088 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt @@ -24,6 +24,7 @@ import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDI import com.swmansion.audioapi.system.notification.NotificationRegistry import com.swmansion.audioapi.system.notification.PlaybackNotification import com.swmansion.audioapi.system.notification.PlaybackNotificationReceiver +import com.swmansion.audioapi.system.notification.RecordingNotification import java.lang.ref.WeakReference object MediaSessionManager { @@ -262,5 +263,29 @@ object MediaSessionManager { notificationRegistry.hideNotification(key) } + /** + * Hides the recording notification without knowing its JS-chosen key. Used by the + * notification stop action, which also unwinds the foreground service through the + * registry's unsubscribe path. + */ + fun hideRecordingNotification() { + if (!::notificationRegistry.isInitialized) { + return + } + notificationRegistry.hideNotificationByNotificationId(RecordingNotification.ID) + } + + /** + * Flips the recording notification between its pause and resume looks. Used by + * native-initiated pause/resume, which can't go through [showNotification] — there + * is no JS to supply options. + */ + fun setRecordingNotificationPaused(paused: Boolean) { + if (!::notificationRegistry.isInitialized) { + return + } + notificationRegistry.updateRecordingNotificationPausedState(paused) + } + fun isNotificationActive(key: String): Boolean = notificationRegistry.isNotificationActive(key) } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt new file mode 100644 index 000000000..a54727a54 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt @@ -0,0 +1,30 @@ +package com.swmansion.audioapi.system + +/** + * Direct access to the active C++ recorder, independent of the React context and the JS + * runtime. This is what allows the recording-notification stop action to end a recording + * after the app task has been removed. + */ +object NativeRecorderControl { + init { + System.loadLibrary("react-native-audio-api") + } + + /** + * Stops the active recording and finalizes its output file. Blocking — never call on + * the main thread. The file info is stashed natively for + * `AudioRecorder.takeLastRecordingResult()` on the JS side. + * + * @return true if a recording was stopped by this call. + */ + external fun stopActiveRecording(): Boolean + + /** Pauses an actively recording session. @return true if this call paused it. */ + external fun pauseActiveRecording(): Boolean + + /** Resumes a paused session. @return true if this call resumed it. */ + external fun resumeActiveRecording(): Boolean + + /** Non-blocking check whether a recording session (recording or paused) is active. */ + external fun isRecordingActive(): Boolean +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt index 0ab29555d..c94870ec0 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -1,5 +1,6 @@ package com.swmansion.audioapi.system.notification +import android.annotation.SuppressLint import android.app.Notification import android.util.Log import androidx.annotation.RequiresPermission @@ -105,6 +106,38 @@ class NotificationRegistry( } } + /** + * Hide a notification by its Android notification ID. + * Used by native-initiated flows (e.g. the recording stop action) that don't know + * the JS-chosen key. + * + * @param id The Android notification ID, e.g. [RecordingNotification.ID] + */ + fun hideNotificationByNotificationId(id: Int) { + notifications.entries + .firstOrNull { it.value.getNotificationId() == id } + ?.let { hideNotification(it.key) } + } + + /** + * Rebuild and re-post the recording notification with a new paused state. + * Used by native-initiated pause/resume so the action button flips even when JS + * is unreachable. No-op unless the recording notification is currently visible — + * which also means the POST_NOTIFICATIONS permission was already granted. + */ + @SuppressLint("MissingPermission") + fun updateRecordingNotificationPausedState(paused: Boolean) { + val entry = + notifications.entries.firstOrNull { + it.value.getNotificationId() == RecordingNotification.ID + } ?: return + if (!activeNotifications.getOrDefault(entry.key, false)) { + return + } + val recordingNotification = entry.value as? RecordingNotification ?: return + displayNotification(RecordingNotification.ID, recordingNotification.rebuildWithPausedState(paused)) + } + /** * Create a notification instance. * diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index 38924a24a..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 @@ -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,199 @@ 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", + ), + ) + } else { + builder.addAction( + createAction( + context, + RecordingNotificationReceiver.ACTION_PAUSE, + REQUEST_CODE_PAUSE, + state.pauseActionTitle ?: "Pause", + ), + ) } - return builder.build() + if (state.showStopAction) { + builder.addAction( + createAction( + context, + RecordingNotificationReceiver.ACTION_STOP, + REQUEST_CODE_STOP, + state.stopActionTitle ?: "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, + ): 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(null, 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 +229,86 @@ 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.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.pauseIconResourceName = - if (options?.hasKey("pauseIconResourceName") == - true - ) { - options.getString("pauseIconResourceName") + state.stopActionTitle = + if (options?.hasKey("stopActionTitle") == true) { + options.getString("stopActionTitle") } else { - state.pauseIconResourceName ?: null + state.stopActionTitle } - state.resumeIconResourceName = - if (options?.hasKey("resumeIconResourceName") == - true - ) { - options.getString("resumeIconResourceName") + 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.resumeIconResourceName ?: null + state.usesChronometer } - state.backgroundColor = if (options?.hasKey("color") == true) options.getInt("color") else state.backgroundColor ?: null + // 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..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 @@ -6,14 +6,21 @@ 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" + private const val TAG = "RecordingNotificationReceiver" + + private val controlExecutor = Executors.newSingleThreadExecutor() } override fun onReceive( @@ -21,14 +28,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..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 @@ -1,24 +1,27 @@ 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, var smallIconResourceName: String? = null, var largeIconResourceName: String? = null, - var pauseIconResourceName: String? = null, - var resumeIconResourceName: 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..ab7ef6fec 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 @@ -21,6 +21,8 @@ class AudioRecorderHostObject : public HostObject { const std::shared_ptr &callInvoker, AudioRecorderOptions options); + ~AudioRecorderHostObject() override; + JSI_HOST_FUNCTION_DECL(start); JSI_HOST_FUNCTION_DECL(stop); JSI_HOST_FUNCTION_DECL(isRecording); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp index bcfa85d5b..5936ff32a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp @@ -148,6 +148,8 @@ AudioEvent audioEventFromString(const std::string &event) { return AudioEvent::BUFFER_ENDED; if (event == "recorderError") return AudioEvent::RECORDER_ERROR; + if (event == "recordingNotificationStop") + return AudioEvent::RECORDING_NOTIFICATION_STOP; throw std::invalid_argument("Unknown audio event: " + event); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp new file mode 100644 index 000000000..39ccfe990 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include +#include +#include + +namespace audioapi { + +ActiveRecorderHandle &ActiveRecorderHandle::global() { + static ActiveRecorderHandle handle; + return handle; +} + +void ActiveRecorderHandle::setRecorder(const std::shared_ptr &recorder) { + std::scoped_lock lock(mutex_); + recorder_ = recorder; +} + +void ActiveRecorderHandle::clearRecorder(const AudioRecorder *recorder) { + std::scoped_lock lock(mutex_); + auto current = recorder_.lock(); + if (current != nullptr && current.get() != recorder) { + return; + } + recorder_.reset(); +} + +bool ActiveRecorderHandle::isRecordingOngoing() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + return recorder != nullptr && !recorder->isIdle(); +} + +bool ActiveRecorderHandle::pauseActiveRecording() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + if (recorder == nullptr || !recorder->isRecording()) { + return false; + } + recorder->pause(); + return true; +} + +bool ActiveRecorderHandle::resumeActiveRecording() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + if (recorder == nullptr || !recorder->isPaused()) { + return false; + } + recorder->resume(); + return true; +} + +bool ActiveRecorderHandle::stopActiveRecording() { + std::shared_ptr recorder; + { + std::scoped_lock lock(mutex_); + recorder = recorder_.lock(); + } + if (recorder == nullptr || recorder->isIdle()) { + return false; + } + + // stop() blocks on file finalization and the recorder's destructor may call + // clearRecorder() concurrently, so mutex_ must not be held around it. + auto result = recorder->stop(); + if (!result.is_ok()) { + return false; + } + + auto [paths, size, duration] = result.unwrap(); + if (!paths.empty()) { + std::scoped_lock lock(mutex_); + lastResult_ = + RecordingStopResult{.paths = std::move(paths), .size = size, .duration = duration}; + } + return true; +} + +std::optional ActiveRecorderHandle::takeLastRecordingResult() { + std::scoped_lock lock(mutex_); + auto result = std::move(lastResult_); + lastResult_.reset(); + return result; +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h new file mode 100644 index 000000000..b442dc694 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace audioapi { + +class AudioRecorder; + +struct RecordingStopResult { + std::vector paths; + double size; + double duration; +}; + +/// @brief Process-global handle to the live AudioRecorder, reachable without a JS runtime. +/// +/// The recorder is otherwise owned solely by its JS-side host object, so platform code +/// (e.g. the Android recording-notification STOP action) has no way to reach it once the +/// JS runtime is unreachable, and a fresh JS context has no way to learn that a recording +/// outlived the app UI. This handle closes both gaps: it can stop the recording natively +/// and it stashes the resulting file info until JS collects it. +/// +/// Assumes at most one AudioRecorder is alive at a time; setting a new recorder replaces +/// the previous one. +class ActiveRecorderHandle { + public: + static ActiveRecorderHandle &global(); + + void setRecorder(const std::shared_ptr &recorder); + + /// @brief Detaches the recorder, but only if the slot still holds @p recorder. + void clearRecorder(const AudioRecorder *recorder); + + /// @brief True while a recording session is active; a paused recording counts as + /// ongoing because it still owns an open output file. + bool isRecordingOngoing(); + + /// @return true if an actively recording session was paused by this call. + bool pauseActiveRecording(); + + /// @return true if a paused session was resumed by this call. + bool resumeActiveRecording(); + + /// @brief Stops a non-idle recording and stashes its file info for + /// takeLastRecordingResult(). Blocks until the output file is finalized — + /// never call on a UI thread. + /// @return true if this call stopped the recording. Losing a race with a + /// JS-initiated stop() returns false; the JS promise delivers that result. + bool stopActiveRecording(); + + /// @brief Consume-once: returns the file info stashed by stopActiveRecording() + /// and clears it, or std::nullopt when nothing is stashed. + std::optional takeLastRecordingResult(); + + private: + std::mutex mutex_; + std::weak_ptr recorder_; + std::optional lastResult_; +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp index 7d0cca29c..5f83332bd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp @@ -76,10 +76,6 @@ void AudioRecorderCallback::invokeCallback( framesEmitted_ += numFrames; } -void AudioRecorderCallback::assignOnErrorCallbackId(uint64_t callbackId) { - errorEvent_.assignCallbackId(callbackId); -} - /// @brief Invokes the error callback with the provided message. /// @param message The error message to be sent to the callback. void AudioRecorderCallback::invokeOnErrorCallback(const std::string &message) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h index 111e7e27e..b4582c8be 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioRecorderCallback.h @@ -42,7 +42,11 @@ class AudioRecorderCallback { void clearOnErrorCallback() { assignOnErrorCallbackId(0); } - void assignOnErrorCallbackId(uint64_t callbackId); + // Defined inline so AudioRecorder.cpp doesn't drag this class's whole + // translation unit (and its HostObject dependency) into the C++ test build. + void assignOnErrorCallbackId(uint64_t callbackId) { + errorEvent_.assignCallbackId(callbackId); + } void invokeOnErrorCallback(const std::string &message); protected: diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h index 2f669d76a..1396a7143 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h @@ -28,5 +28,6 @@ enum class AudioEvent : uint8_t { POSITION_CHANGED, BUFFER_ENDED, RECORDER_ERROR, + RECORDING_NOTIFICATION_STOP, }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp new file mode 100644 index 000000000..6bce4cd06 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp @@ -0,0 +1,192 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace audioapi; + +// NOLINTBEGIN + +namespace { + +class FakeAudioRecorder : public AudioRecorder { + public: + FakeAudioRecorder() : AudioRecorder(nullptr) {} + + std::vector stopPaths{"file:///tmp/recording.m4a"}; + std::atomic stopCount{0}; + + Result start(const std::string &) override { + state_ = RecorderState::Recording; + return Ok(None); + } + + // Mirrors AndroidAudioRecorder::stop(): under its locks exactly one caller + // transitions out of a non-idle state and closes the file; the loser errs. + Result, double, double>, std::string> stop() override { + if (state_.exchange(RecorderState::Idle) == RecorderState::Idle) { + return Err(std::string("Recorder is not in recording state.")); + } + stopCount += 1; + return Ok(std::make_tuple(stopPaths, 1.5, 10.0)); + } + + Result enableFileOutput(std::shared_ptr) override { + return Ok(None); + } + void disableFileOutput() override {} + + void pause() override { + state_ = RecorderState::Paused; + } + void resume() override { + state_ = RecorderState::Recording; + } + + void connect(const std::shared_ptr &) override {} + void disconnect() override {} + + Result setOnAudioReadyCallback(float, size_t, int, uint64_t) override { + return Ok(None); + } + void clearOnAudioReadyCallback() override {} + + bool isRecording() const override { + return state_ == RecorderState::Recording; + } + bool isPaused() const override { + return state_ == RecorderState::Paused; + } + bool isIdle() const override { + return state_ == RecorderState::Idle; + } + + [[nodiscard]] double getInputLatency() const override { + return 0.0; + } +}; + +} // namespace + +TEST(ActiveRecorderHandleTest, EmptySlotReportsNoRecordingAndStopsNothing) { + ActiveRecorderHandle handle; + + EXPECT_FALSE(handle.isRecordingOngoing()); + EXPECT_FALSE(handle.stopActiveRecording()); + EXPECT_FALSE(handle.takeLastRecordingResult().has_value()); +} + +TEST(ActiveRecorderHandleTest, IdleRecorderIsNotOngoing) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + + EXPECT_FALSE(handle.isRecordingOngoing()); + EXPECT_FALSE(handle.stopActiveRecording()); +} + +TEST(ActiveRecorderHandleTest, RecordingAndPausedCountAsOngoing) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + + recorder->start(""); + EXPECT_TRUE(handle.isRecordingOngoing()); + + recorder->pause(); + EXPECT_TRUE(handle.isRecordingOngoing()); +} + +TEST(ActiveRecorderHandleTest, PauseAndResumeActOnlyInMatchingStates) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + + EXPECT_FALSE(handle.pauseActiveRecording()); + EXPECT_FALSE(handle.resumeActiveRecording()); + + recorder->start(""); + EXPECT_FALSE(handle.resumeActiveRecording()); + EXPECT_TRUE(handle.pauseActiveRecording()); + EXPECT_TRUE(recorder->isPaused()); + + EXPECT_FALSE(handle.pauseActiveRecording()); + EXPECT_TRUE(handle.resumeActiveRecording()); + EXPECT_TRUE(recorder->isRecording()); +} + +TEST(ActiveRecorderHandleTest, StopStashesResultForSingleConsumption) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + + EXPECT_TRUE(handle.stopActiveRecording()); + EXPECT_FALSE(handle.isRecordingOngoing()); + + auto result = handle.takeLastRecordingResult(); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->paths, recorder->stopPaths); + EXPECT_DOUBLE_EQ(result->size, 1.5); + EXPECT_DOUBLE_EQ(result->duration, 10.0); + + EXPECT_FALSE(handle.takeLastRecordingResult().has_value()); +} + +TEST(ActiveRecorderHandleTest, StopWithoutFileOutputStashesNothing) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + recorder->stopPaths.clear(); + handle.setRecorder(recorder); + recorder->start(""); + + EXPECT_TRUE(handle.stopActiveRecording()); + EXPECT_FALSE(handle.takeLastRecordingResult().has_value()); +} + +TEST(ActiveRecorderHandleTest, ExpiredRecorderReportsNoRecording) { + ActiveRecorderHandle handle; + { + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + } + + EXPECT_FALSE(handle.isRecordingOngoing()); + EXPECT_FALSE(handle.stopActiveRecording()); +} + +TEST(ActiveRecorderHandleTest, ClearRecorderIgnoresForeignPointer) { + ActiveRecorderHandle handle; + auto current = std::make_shared(); + auto other = std::make_shared(); + handle.setRecorder(current); + current->start(""); + + handle.clearRecorder(other.get()); + EXPECT_TRUE(handle.isRecordingOngoing()); + + handle.clearRecorder(current.get()); + EXPECT_FALSE(handle.isRecordingOngoing()); +} + +TEST(ActiveRecorderHandleTest, ConcurrentStopsCloseTheFileExactlyOnce) { + ActiveRecorderHandle handle; + auto recorder = std::make_shared(); + handle.setRecorder(recorder); + recorder->start(""); + + std::thread nativeStop([&handle] { handle.stopActiveRecording(); }); + std::thread jsStop([&recorder] { recorder->stop(); }); + nativeStop.join(); + jsStop.join(); + + EXPECT_EQ(recorder->stopCount, 1); +} + +// NOLINTEND diff --git a/packages/react-native-audio-api/src/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: [], diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index 3f1060233..d6f868c42 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -69,14 +69,35 @@ export interface RecordingNotificationInfo { paused?: boolean; smallIconResourceName?: string; largeIconResourceName?: string; - pauseIconResourceName?: string; - 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; + /** 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;