Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de
| Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) |
| CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread |
| Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` |
| Platform code (Kotlin) must reach a C++ object with no JS runtime alive | Process-global handle (`ActiveRecorderHandle` — mutex + `weak_ptr`, registered by the HostObject ctor/dtor) + static-JNI `JavaClass` (`NativeRecorderControl`, no HybridData needed). Blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` — Kotlin threads are already JNI-attached |

---

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<service>` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal
- **JS-Independent Recorder Control (Android)**: the recording notification's stop action must work after task removal, when no JS listener is reachable. `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` registered by `AudioRecorderHostObject`) exposes the live recorder process-globally; Kotlin reaches it through the static-JNI `NativeRecorderControl` object (no HybridData/React context needed — the reverse of the `NativeFileInfo` pattern). Results of a native stop are stashed consume-once in the handle
- **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr<I…>`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`).

### Native Module Entry Points
Expand Down
2 changes: 0 additions & 2 deletions apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ const Record: FC = () => {
contentText: paused ? 'Paused recording' : 'Recording...',
paused,
smallIconResourceName: 'logo',
pauseIconResourceName: 'pause',
resumeIconResourceName: 'resume',
color: 0xff6200,
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<service android:stopWithTask="true" android:name="com.swmansion.audioapi.system.CentralizedForegroundService" android:foregroundServiceType="mediaPlayback|microphone" />
<service android:stopWithTask="false" android:name="com.swmansion.audioapi.system.CentralizedForegroundService" android:foregroundServiceType="mediaPlayback|microphone" />
<activity
android:name=".MainActivity"
android:label="@string/app_name"
Expand Down
42 changes: 42 additions & 0 deletions packages/audiodocs/docs/inputs/audio-recorder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,48 @@ Additionally to be able to record audio while application is in the background,
</TabItem>
</Tabs>

### 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:

<Tabs queryString="platform">
<TabItem value="expo" label="Expo" default>
Set the `androidFSStopWithTask` option of the [expo plugin](/docs/other/audio-api-plugin#androidfsstopwithtask) to `false`:

```json
{
"plugins": [
[
"react-native-audio-api",
{
"androidFSStopWithTask": false
}
]
]
}
```

</TabItem>
<TabItem value="android" label="Android">
In a bare react-native application, set `android:stopWithTask="false"` on the service entry in your `AndroidManifest.xml`:

```xml
<service android:stopWithTask="false" android:name="com.swmansion.audioapi.system.CentralizedForegroundService" android:foregroundServiceType="microphone" />
```

</TabItem>
</Tabs>

For the recording to actually survive, all of the following must hold:

- The foreground service only exists while a library notification is shown. Call [`RecordingNotificationManager.show()`](/docs/system/recording-notification-manager#show) while the app is still in the foreground — before the user leaves the app — otherwise there is no service to keep alive.
- `androidFSTypes` must include `"microphone"` (manifest `foregroundServiceType="microphone"`), and on Android 14+ (API 34) the app needs the `android.permission.FOREGROUND_SERVICE_MICROPHONE` permission.
- Android's while-in-use rule applies: microphone access must begin while the app is in the foreground. Starting a recording from the background is not possible.

:::caution
Even with `stopWithTask="false"`, the system can still kill the process (memory pressure, OEM battery managers). Recording cannot self-restart from the background — the user has to reopen the app. To limit data loss in that case, tune the file-output options [`androidFlushIntervalMs`](/docs/inputs/audio-recorder#androidflushintervalms) and [`rotateIntervalBytes`](/docs/inputs/audio-recorder#audiorecorderfileoptions).
:::

## Examples

<Tabs group="usage">
Expand Down
13 changes: 13 additions & 0 deletions packages/audiodocs/docs/other/audio-api-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface Options {
androidPermissions: string[];
androidForegroundService: boolean;
androidFSTypes: string[];
androidFSStopWithTask?: boolean;
}
```

Expand Down Expand Up @@ -133,3 +134,15 @@ Types description:
Runtime prerequisites:

- Request and be granted the RECORD_AUDIO runtime permission.

### `androidFSStopWithTask`

Defaults to `true`.

Controls the `android:stopWithTask` attribute of the Foreground Service injected by the plugin. With the default value (`true`), the service stops when the user swipes the app away from the recents screen.

Set it to `false` to emit `android:stopWithTask="false"` on the service entry — on task removal the service keeps running, which keeps the app process (and e.g. an in-progress recording) alive.

:::info
The Foreground Service only exists while a library notification is shown, so this option has an effect only if a notification (e.g. via `RecordingNotificationManager.show()`) is displayed before the user closes the app. See [keeping the recording alive when the app is closed](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements.
:::
51 changes: 41 additions & 10 deletions packages/audiodocs/docs/system/recording-notification-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
# RecordingNotificationManager <Android />

The `RecordingNotificationManager` provides system integration with [`AudioRecorder`](/docs/inputs/audio-recorder) on Android.
It can send events about pausing and resuming to your application.
It shows a standard notification with pause/resume and (optionally) stop actions, can route notification taps to a specific screen, and sends action events to your application.

:::note iOS
`RecordingNotificationManager` is not available on iOS. For a recording indicator on the Lock Screen and in the Dynamic Island, use a [Live Activity](https://docs.expo.dev/versions/latest/sdk/widgets/) built with [`expo-widgets`](https://docs.expo.dev/versions/latest/sdk/widgets/).
Expand All @@ -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', () => {
Expand All @@ -34,19 +35,45 @@ const pauseEventListener = RecordingNotificationManager.addEventListener('record
const resumeEventListener = RecordingNotificationManager.addEventListener('recordingNotificationResume', () => {
console.log('Notification resume action received');
});
const stopEventListener = RecordingNotificationManager.addEventListener('recordingNotificationStop', () => {
console.log('Notification stop action received');
});

pauseEventListener.remove();
resumeEventListener.remove();
stopEventListener.remove();
RecordingNotificationManager.hide();
```

## Native action handling

All notification actions act on the recorder **natively**, without a JS round-trip. This matters when the recording outlives the app UI (see [keeping the recording alive when the app is closed](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed)) — pause, resume and stop keep working even after the app task has been removed and no JS listener is reachable.

- **Pause / resume** pause or resume the recorder and flip the notification's action button. The matching event (`recordingNotificationPause` / `recordingNotificationResume`) still fires so a live app can sync its UI — handlers calling `AudioRecorder.pause()` / `resume()` again are harmless, the recorder ignores same-state transitions.
- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.takeLastRecordingResult()`](/docs/inputs/audio-recorder#takelastrecordingresult)), emits `recordingNotificationStop`, then hides the notification, which also stops the foreground service. Unlike pause/resume, your `recordingNotificationStop` listener should **not** call `AudioRecorder.stop()` — the recording is already stopped. Collect the files with `AudioRecorder.takeLastRecordingResult()` instead.

## Routing the notification tap

By default, tapping the notification opens the app's launcher activity. Set `deepLinkUri` to attach a URI to the tap intent; React Native delivers it through [`Linking`](https://reactnative.dev/docs/linking) (`getInitialURL()` on cold start, the `url` event otherwise). With React Navigation, map it to a screen with a [`linking` config](https://reactnavigation.org/docs/deep-linking/):

```tsx
const linking = {
prefixes: ['myapp://'],
config: { screens: { RecordScreen: 'record' } },
};

<NavigationContainer linking={linking}>
```

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|
| :---: | :---: | :---- |
Expand All @@ -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`
Expand Down Expand Up @@ -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
}
```
</details>
Expand All @@ -117,6 +147,7 @@ interface EventEmptyType {}
interface RecordingNotificationEvent {
recordingNotificationPause: EventEmptyType;
recordingNotificationResume: EventEmptyType;
recordingNotificationStop: EventEmptyType;
}

type RecordingNotificationEventName = keyof RecordingNotificationEvent;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
#include <audioapi/android/AudioAPIModule.h>
#include <audioapi/android/system/NativeRecorderControl.hpp>

#include <fbjni/fbjni.h>

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();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,8 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
return;
}

const auto stateBeforeTeardown = state_.load(std::memory_order_acquire);

cleanup();

auto streamResult = openAudioStream();
Expand All @@ -610,8 +612,13 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
return;
}

mStream_->requestStart();
state_.store(RecorderState::Recording, std::memory_order_release);
// Restore the interrupted session's state instead of unconditionally recording —
// a paused session must stay paused, or the reopened stream would silently turn
// the microphone back on against an explicit user action.
if (stateBeforeTeardown == RecorderState::Recording) {
mStream_->requestStart();
}
state_.store(stateBeforeTeardown, std::memory_order_release);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#include <audioapi/android/system/NativeRecorderControl.hpp>

#include <audioapi/core/inputs/ActiveRecorderHandle.h>

namespace audioapi {

void NativeRecorderControl::registerNatives() {
javaClassStatic()->registerNatives({
makeNativeMethod("stopActiveRecording", NativeRecorderControl::stopActiveRecording),
makeNativeMethod("pauseActiveRecording", NativeRecorderControl::pauseActiveRecording),
makeNativeMethod("resumeActiveRecording", NativeRecorderControl::resumeActiveRecording),
makeNativeMethod("isRecordingActive", NativeRecorderControl::isRecordingActive),
});
}

jboolean NativeRecorderControl::stopActiveRecording(jni::alias_ref<jni::JClass> /*clazz*/) {
return static_cast<jboolean>(ActiveRecorderHandle::global().stopActiveRecording());
}

jboolean NativeRecorderControl::pauseActiveRecording(jni::alias_ref<jni::JClass> /*clazz*/) {
return static_cast<jboolean>(ActiveRecorderHandle::global().pauseActiveRecording());
}

jboolean NativeRecorderControl::resumeActiveRecording(jni::alias_ref<jni::JClass> /*clazz*/) {
return static_cast<jboolean>(ActiveRecorderHandle::global().resumeActiveRecording());
}

jboolean NativeRecorderControl::isRecordingActive(jni::alias_ref<jni::JClass> /*clazz*/) {
return static_cast<jboolean>(ActiveRecorderHandle::global().isRecordingOngoing());
}

} // namespace audioapi
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#pragma once

#include <fbjni/fbjni.h>

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<NativeRecorderControl> {
public:
static auto constexpr kJavaDescriptor = "Lcom/swmansion/audioapi/system/NativeRecorderControl;";

static void registerNatives();

static jboolean stopActiveRecording(jni::alias_ref<jni::JClass>);
static jboolean pauseActiveRecording(jni::alias_ref<jni::JClass>);
static jboolean resumeActiveRecording(jni::alias_ref<jni::JClass>);
static jboolean isRecordingActive(jni::alias_ref<jni::JClass>);
};

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ enum class AudioEvent {
POSITION_CHANGED,
BUFFER_ENDED,
RECORDER_ERROR,
RECORDING_NOTIFICATION_STOP,
}
Loading
Loading