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 4c20d4c2d..3d6d4bed0 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 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 @@ -51,9 +61,10 @@ const Record: FC = () => { contentText: paused ? 'Paused recording' : 'Recording...', paused, smallIconResourceName: 'logo', - pauseIconResourceName: 'pause', - resumeIconResourceName: 'resume', color: 0xff6200, + showStopAction: true, + deepLinkUri: 'audioapi-example://record', + usesChronometer: true, }); }; @@ -118,6 +129,19 @@ const Record: FC = () => { setState(RecordingState.Recording); }, []); + const loadRecordedAudio = useCallback( + async (paths: string[]) => { + setState(RecordingState.Loading); + + const audioBuffer = await audioContext.decodeAudioData(paths[0]); + setRecordedBuffer(audioBuffer); + + setState(RecordingState.ReadyToPlay); + currentPositionSV.value = 0; + }, + [currentPositionSV] + ); + const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); RecordingNotificationManager.hide(); @@ -130,15 +154,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) { @@ -229,11 +260,21 @@ 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'); + } + } })(); }, []); @@ -254,22 +295,50 @@ 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(() => { + if (AudioRecorder.isRecordingOngoing()) { + return; + } + + const info = AudioRecorder.takeLastRecordingResult(); + if (info && info.paths.length > 0) { + loadRecordedAudio(info.paths); + } + }, [loadRecordedAudio]); useEffect(() => { - Recorder.enableFileOutput({ rotateIntervalBytes: 1_000_000, format: FileFormat.M4A }); + // 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(); + + if (!AudioRecorder.isRecordingOngoing()) { + AudioManager.setAudioSessionActivity(false); + } }; }, [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/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"> - + ` 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. + +A badge in a heading also leaks into its slug — `### \`takeLastRecordingResult\` ` +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 1c7c5b8b2..c5b93b5aa 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -93,6 +93,63 @@ 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} + +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.mdx#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()`](../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`](#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.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: + +```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 @@ -462,12 +519,43 @@ Returns the current recording duration when file output is enabled. const duration = audioRecorder.getCurrentDuration(); ``` +### `isRecordingOngoing` + +**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`. + +```tsx +if (AudioRecorder.isRecordingOngoing()) { + // re-attach the UI to the still-running recording +} +``` + +### `takeLastRecordingResult` {#takelastrecordingresult} + +**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`](#stop) resolve their promise with the file info instead and never appear here. + +#### Returns [`FileInfo`](#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. | @@ -690,7 +778,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 ecf3dae12..de6754127 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](../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 421b9a722..14d5b594b 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`](../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](../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.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 + +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..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 @@ -591,8 +591,17 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re return; } + const auto stateBeforeTeardown = state_.load(std::memory_order_acquire); + 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()) { @@ -610,8 +619,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..7baf9c076 --- /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("isRecordingOngoing", NativeRecorderControl::isRecordingOngoing), + }); +} + +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::isRecordingOngoing(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..fc80c7ef6 --- /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 isRecordingOngoing(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/CentralizedForegroundService.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt index 086e348a3..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 @@ -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,27 +52,29 @@ 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() + createLowImportanceChannelIfNeeded(CHANNEL_ID, "Audio Service", "Background audio processing") // 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,87 @@ class CentralizedForegroundService : Service() { } } + private fun startForegroundWithPlaceholderAndStop() { + createLowImportanceChannelIfNeeded( + PLACEHOLDER_CHANNEL_ID, + "Audio Service Placeholder", + "Short-lived notification shown while the audio service shuts down", + ) + + val placeholderNotification = + NotificationCompat + .Builder(this, PLACEHOLDER_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("Audio service") + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + 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( + 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 { @@ -86,28 +176,36 @@ 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 } + + 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() { 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 900ad1a04..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 @@ -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.hideNotification(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..9be556d3e --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt @@ -0,0 +1,34 @@ +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. + */ + @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 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 0ab29555d..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,23 +98,60 @@ 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)") } } + /** + * 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] + */ + @Synchronized + fun hideNotification(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. + */ + @Synchronized + @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. * @@ -149,6 +196,7 @@ class NotificationRegistry( * * @param key The unique identifier of the notification */ + @Synchronized fun destroyNotification(key: String) { hideNotification(key) notifications.remove(key) @@ -159,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) @@ -202,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 38924a24a..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 @@ -4,23 +4,18 @@ 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 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 @@ -29,196 +24,231 @@ 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) + } + + // 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() - val pauseId = - if (state.pauseIconResourceName != null) { - context.resources.getIdentifier(state.pauseIconResourceName, "drawable", context.packageName) - } else { - android.R.drawable.ic_media_pause + 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 + builder + .setWhen(state.startedAtMs!!) + .setShowWhen(true) + .setUsesChronometer(true) + } else { + if (state.usesChronometer && state.paused && state.pausedAtMs == null) { + state.pausedAtMs = now + } + builder + .setUsesChronometer(false) + .setShowWhen(false) + } } - 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 - } - 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) + private fun resolveDrawable( + context: Context, + resourceName: String?, + ): Int? { + if (resourceName == null) { + return null } + val resourceId = context.resources.getIdentifier(resourceName, "drawable", context.packageName) + return if (resourceId != 0) resourceId else null } -// 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 parseMapFromRN(options: ReadableMap?) { + 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 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) + private fun ReadableMap?.stringOr( + key: String, + fallback: String?, + ): String? = if (this?.hasKey(key) == true) getString(key) else fallback - state.builder = - NotificationCompat - .Builder(context, channelId) - .setOngoing(true) - .setContentIntent(pendingIntent) - } - if (state.smallIconResourceName == null) { - state.builder!!.setSmallIcon(android.R.drawable.ic_btn_speak_now) - } - return state.builder!! - } + 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) { @@ -238,84 +268,20 @@ class RecordingNotification( Log.d(TAG, "Notification channel created: $channelId") } - 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 ?: null - } - state.largeIconResourceName = - if (options?.hasKey("largeIconResourceName") == - true - ) { - options.getString("largeIconResourceName") - } else { - state.largeIconResourceName ?: null - } - state.pauseIconResourceName = - if (options?.hasKey("pauseIconResourceName") == - true - ) { - options.getString("pauseIconResourceName") - } else { - state.pauseIconResourceName ?: null - } - state.resumeIconResourceName = - if (options?.hasKey("resumeIconResourceName") == - true - ) { - options.getString("resumeIconResourceName") - } else { - state.resumeIconResourceName ?: null - } - state.backgroundColor = if (options?.hasKey("color") == true) options.getInt("color") else state.backgroundColor ?: null - state.paused = if (options?.hasKey("paused") == true) options.getBoolean("paused") else false - } - 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..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 @@ -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,15 +28,88 @@ 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) } - NOTIFICATION_RECORDING_RESUMED -> { - Log.d(TAG, "Recording resumed via notification") - module.invokeHandlerWithEventNameAndEventBody(AudioEvent.RECORDING_NOTIFICATION_RESUME.ordinal, mapOf()) + 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 { + 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, + ) + } + } 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) + } finally { + pendingResult.finish() } } } + + /** 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() + // 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() + 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) + } finally { + pendingResult.finish() + } + } + } + + /** 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 b204e3987..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 @@ -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 -data class RecordingNotificationState( - var builder: NotificationCompat.Builder? = null, +/** + * 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. + */ +class RecordingNotificationState( 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/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/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..a32ddd92a --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp @@ -0,0 +1,100 @@ +#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::shared_ptr current; + { + std::scoped_lock lock(mutex_); + 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 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; + } + + 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_); + 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 new file mode 100644 index 000000000..956aad452 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h @@ -0,0 +1,67 @@ +#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 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. +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..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,9 +42,15 @@ class AudioRecorderCallback { void clearOnErrorCallback() { assignOnErrorCallbackId(0); } - void assignOnErrorCallbackId(uint64_t callbackId); 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); + } + protected: std::atomic isInitialized_{false}; 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..18b70d01b --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/inputs/ActiveRecorderHandleTest.cpp @@ -0,0 +1,231 @@ +#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()); +} + +// 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) { + 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 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; + } +} + +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 5bd2be419..9d33c54a4 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) | undefined; + + var takeLastRecordingResult: (() => FileInfo | null) | undefined; + 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..67137f2c7 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -56,6 +56,31 @@ export default class AudioRecorder { this.recorder = globalThis.createAudioRecorder(options ?? {}); } + /** + * 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 (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; + } + /** * 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/src/plugin/withAudioAPI.ts b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts index d11e4052d..6b2481224 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,7 @@ const withForegroundService: ConfigPlugin = ( $: { 'android:name': 'com.swmansion.audioapi.system.CentralizedForegroundService', - 'android:stopWithTask': 'true', + 'android:stopWithTask': String(androidFSStopWithTask), '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 16e295efc..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,14 +77,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; diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index 166a91b3d..48b8be5f5 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 return null when no native stop occurred', () => { + expect(MockAPI.AudioRecorder.takeLastRecordingResult()).toBeNull(); + }); + it('should support RecorderAdapterNode connection', () => { const context = new MockAPI.AudioContext(); const adapter = context.createRecorderAdapter();