Skip to content

Feat/android fgs survive task removal - #1234

Open
mdydek wants to merge 23 commits into
mainfrom
feat/android-fgs-survive-task-removal
Open

mdydek wants to merge 23 commits into
mainfrom
feat/android-fgs-survive-task-removal

Conversation

@mdydek

@mdydek mdydek commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes #

⚠️ Breaking changes ⚠️

  • get rid of the icons in the recording notification, the ui is different based on the manufacturer, android versions, thus the current approach is to fallback to simple text buttons, which will be placed by the system possibly in the correct arrangement
  • enableFileOutput cannot be set once recorder is started

Introduced changes

With stopWithTask=false a recording outlives the app UI, but a remounted screen (or relaunched app) had no way to learn about it — recorder state was only reachable through the instance that started it:

new JSI globals backed by ActiveRecorderHandle, surfaced as statics: AudioRecorder.isRecordingOngoing() and the consume-once AudioRecorder.takeLastRecordingResult() for files finalized by the notification stop action (mock parity + jest coverage included)
Record demo mounts directly in the live recorder's state, picks up natively stopped files, keeps the recording alive across screen exits and only enables file output when no session is ongoing (re-enabling mid-recording replaces the writer and resets the duration)
deep-link routing for the notification tap (react-navigation linking), duration displays seeded from the recorder instead of assuming a fresh session, and RecordingTime rewritten to plain state — the animated-prop binding went stale on the frozen value while paused and showed zeros

possibility of swiping away notification is configurable, it can be easily swiped, thus ending recording or unswipable, making it stick, but in android 14+ it cannot be made for certain notifications, so it is being reattached in this scenario

Checklist

  • Linked relevant issue
  • Updated relevant documentation
  • Added/Conducted relevant tests
  • Performed self-review of the code
  • Updated Web Audio API coverage
  • Added support for web
  • Updated old arch android spec file

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

WPT non-regression comparison

FAIL — 1 regressed section(s) · 0 improved section(s) · overall 2717 → 2716 (-1)

Spec section Base pass Head pass Delta
AudioBufferSourceNode 218 217 -1
Unchanged sections (27)
Spec section Base pass Head pass Delta
Processing model 0 0 0
Other 52 52 0
AnalyserNode 138 138 0
AudioBuffer 140 140 0
AudioContext 58 58 0
AudioNode 261 261 0
AudioParam 629 629 0
BiquadFilterNode 275 275 0
ChannelMergerNode 30 30 0
ChannelSplitterNode 7 7 0
ConstantSourceNode 64 64 0
ConvolverNode 203 203 0
DelayNode 104 104 0
DestinationNode 0 0 0
DynamicsCompressorNode 4 4 0
GainNode 15 15 0
IIRFilterNode 87 87 0
MediaElementAudioSourceNode 0 0 0
MediaStreamAudioDestinationNode 1 1 0
MediaStreamAudioSourceNode 0 0 0
OfflineAudioContext 35 35 0
OscillatorNode 104 104 0
PannerNode 75 75 0
PeriodicWave 33 33 0
ScriptProcessorNode 0 0 0
StereoPannerNode 102 102 0
WaveShaperNode 82 82 0

Baseline: 62ff7683ea93614330ab7d9ab97c3339431ef40a · Candidate: c57f0bff7ef97320393d42f2615ef7a32726b674

Workflow run · this comment is updated on every push.

@mdydek
mdydek marked this pull request as ready for review August 20, 2026 11:38
@closetcaiman closetcaiman added feature New user-facing features or major capabilities android Native Android implementation, C++/Java/Kotlin bindings, or Android-specific issues labels Aug 20, 2026
Comment thread packages/audiodocs/docs/inputs/audio-recorder.mdx Outdated
@closetcaiman
closetcaiman requested review from maciejmakowski2003 and a lite review from Copilot and removed request for Copilot August 24, 2026 08:22
@michalsek
michalsek requested a balanced review from Copilot August 27, 2026 19:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@michalsek
michalsek force-pushed the feat/android-fgs-survive-task-removal branch from a16212c to 6dd83cf Compare August 27, 2026 19:45

@michalsek michalsek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit of comments for later 👉 👈

Comment thread apps/common-app/src/demos/Record/Record.tsx Outdated
Comment thread apps/common-app/src/demos/Record/Record.tsx Outdated
Comment thread apps/common-app/src/demos/Record/Record.tsx Outdated
Comment thread apps/common-app/src/demos/Record/Record.tsx Outdated
Comment thread apps/common-app/src/demos/Record/Record.tsx
Comment thread packages/react-native-audio-api/src/system/notification/types.ts
Comment thread packages/react-native-audio-api/src/plugin/withAudioAPI.ts Outdated
Comment thread packages/react-native-audio-api/src/core/AudioRecorder.ts Outdated
Comment thread packages/react-native-audio-api/src/core/AudioRecorder.ts Outdated
Comment thread packages/audiodocs/docs/system/recording-notification-manager.mdx Outdated

@maciejmakowski2003 maciejmakowski2003 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few issues identified by claude. worth to check.

  • RecordingNotificationReceiver.kt:90 — stopRecordingNatively ignores the stopActiveRecording() return value, so it hides the notification and dispatches recordingNotificationStop even when nothing was stopped. When the handle's slot is empty but a recording is live, this cancels the notification and unsubscribes the mic-typed foreground service while the Oboe stream keeps capturing — no notification, no way to stop. When the stop failed at closeFile(), JS gets a stop event and a null result, silently losing the recording.

  • AudioRecorderHostObject.cpp:35 — the constructor unconditionally overwrites ActiveRecorderHandle's single slot. A second, never-started AudioRecorder displaces a recording one, and when that second one is GC'd, clearRecorder sees an identity match and empties the slot outright — the live recording becomes permanently unreachable to every notification action, and isRecordingOngoing() reports false. The one-recorder assumption is documented, but the failure mode is worse than the doc implies.

  • AndroidAudioRecorder.cpp:253 — stop() is documented "JS thread only" and mutates recordingSegmentPaths_/filePath_ after releasing stopLock; the notification path now calls it from the receiver's executor. If the user starts a new recording while the old stop is blocked in closeFile(), the executor's trailing clear() / filePath_ = "" wipes the new session's path and its later stop() returns no files.

  • ForegroundServiceManager.kt:65 — onServiceDestroyed() clears isServiceRunning unconditionally. A hide-then-show in one tick (recording → playback notification) lets the old instance's onDestroy clear the flag after the restart, and the next unsubscribe then never stops the service — it leaks with no notification behind it.

  • RecordingNotification.kt:235 — paused resets to false when absent while every other option is sticky. A partial show({ contentText: … }) during a natively-paused recording flips the button back to Pause; tapping it no-ops (pauseActiveRecording() returns false), so the notification is stuck and the user can't resume from it.

  • Record.tsx:136 — demo decodes paths[0] only after concatAudioFiles was dropped; with rotateIntervalBytes (which the new docs recommend for exactly this scenario) all segments but the first are silently discarded.

could you please clean-up comments as well.

Comment thread apps/common-app/src/demos/Record/Record.tsx Outdated
Comment thread packages/audiodocs/docs/system/recording-notification-manager.mdx Outdated
Comment thread apps/common-app/src/demos/Record/Record.tsx Outdated
Comment thread apps/common-app/src/demos/Record/RecordingTime.tsx Outdated
michalsek and others added 9 commits September 4, 2026 15:29
The recording notification's pause, resume and stop actions now act on the
recorder natively, so they keep working after the app task is removed while
the foreground service (stopWithTask=false) keeps the recording alive:

- ActiveRecorderHandle: process-global one-slot handle to the live recorder
  (registered by AudioRecorderHostObject), with a consume-once stash of the
  file info produced by a native stop
- NativeRecorderControl: static-JNI entry points callable from Kotlin without
  a React context; the notification receiver stops/pauses/resumes through it
  on an executor and still emits the matching AudioEvent so a live app can
  sync its UI (new event: RECORDING_NOTIFICATION_STOP)
- RecordingNotification rewritten to standard NotificationCompat actions
  (RemoteViews layouts removed), rebuilt on every show(); adds stop action,
  action titles, deepLinkUri tap routing (ACTION_VIEW) and a chronometer that
  excludes paused spans; native pause/resume re-post the notification so the
  action button flips without JS
- onErrorAfterClose now restores the pre-teardown state after a stream
  reclaim instead of force-resuming a paused recording

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With stopWithTask=false a recording outlives the app UI, but a remounted
screen (or relaunched app) had no way to learn about it — recorder state was
only reachable through the instance that started it:

- new JSI globals backed by ActiveRecorderHandle, surfaced as statics:
  AudioRecorder.isRecordingOngoing() and the consume-once
  AudioRecorder.takeLastRecordingResult() for files finalized by the
  notification stop action (mock parity + jest coverage included)
- Record demo mounts directly in the live recorder's state, picks up
  natively stopped files, keeps the recording alive across screen exits and
  only enables file output when no session is ongoing (re-enabling
  mid-recording replaces the writer and resets the duration)
- deep-link routing for the notification tap (react-navigation linking),
  duration displays seeded from the recorder instead of assuming a fresh
  session, and RecordingTime rewritten to plain state — the animated-prop
  binding went stale on the frozen value while paused and showed zeros

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maciejmakowski2003
maciejmakowski2003 force-pushed the feat/android-fgs-survive-task-removal branch from da2b737 to 4b82eda Compare September 4, 2026 13:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Recorder-handle races, active-recorder displacement, stale service types, and misleading file-output success results can break the core recording-survival flow.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp:45

  • This reads the shared weak_ptr before taking the mutex, racing with recorder registration/destruction. Lock first so the weak_ptr::lock() operation is synchronized with assignments and resets.
    packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp:54
  • As in the other handle methods, accessing recorder_ before acquiring destructorMutex_ races with setRecorder()/clearRecorder(). Reverse these statements.
    packages/react-native-audio-api/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp:63
  • The notification-stop path also locks the shared weak_ptr before taking its mutex. Concurrent HostObject creation/destruction can therefore race this read; acquire destructorMutex_ first.
  • Files reviewed: 57/58 changed files
  • Comments generated: 10
  • Review effort level: Balanced

Comment on lines +289 to +291
if (!isIdle()) {
return Result<NoneType, std::string>::Ok(None);
}
Comment on lines +509 to +511
if (!isIdle()) {
return Result<NoneType, std::string>::Ok(None);
}
Comment on lines +45 to +46
sed 's/=.*//; s/[[:space:]]//g' |
grep -E '^[A-Za-z_][A-Za-z0-9_]*$' || true
Comment on lines +59 to +63
static isRecordingOngoing(): boolean {
return globalThis.isRecordingOngoing?.() ?? false;
}

static takeLastRecordingResult(): FileInfo | null {
| 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 |
title?: string;
contentText?: string;
paused?: boolean; // flag indicating whether to display pauseIcon or resumeIcon
paused?: boolean;
mdydek and others added 2 commits September 11, 2026 17:41
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the conflicts This PR has conflicts with the base branch. label Sep 14, 2026
@github-actions github-actions Bot added conflicts This PR has conflicts with the base branch. and removed conflicts This PR has conflicts with the base branch. labels Sep 14, 2026
@github-actions github-actions Bot removed the conflicts This PR has conflicts with the base branch. label Sep 15, 2026

@maciejmakowski2003 maciejmakowski2003 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you test what happens if user dismisses notification. strip off or edit comments added by ai. verify logs usefulness.

Comment thread packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm Outdated
Comment on lines +509 to -529
if (!isIdle()) {
return Result<NoneType, std::string>::Ok(None);
}

std::scoped_lock lock(fileWriterMutex_, errorCallbackMutex_);
fileProperties_ = properties;
fileOutputEnabled_.store(true, std::memory_order_release);
fileOutputConfigured_.store(false, std::memory_order_release);

if (!isIdle()) {
AVAudioFormat *resolvedInputFormat = [nativeRecorder_ getResolvedInputFormat];
int resolvedBufferSize = [nativeRecorder_ getResolvedBufferSize];

if (!hasUsableRecorderFormat(resolvedInputFormat) || resolvedBufferSize <= 0) {
return Result<NoneType, std::string>::Err(
"Failed to open file for writing: recorder input format is unavailable");
}

auto writerResult = setupFileWriter(properties);
if (writerResult.is_err()) {
fileOutputEnabled_.store(false, std::memory_order_release);
return Result<NoneType, std::string>::Err(writerResult.unwrap_err());
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this changed the behavior that we cannot suddenly enable file output in ongoing recorder, thus we don't need that code, we can discuss it internally if we would like to support this, if yes we can revert that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recheck comments in both .h and .cpp file and use singleton pattern instead of global method

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not agree with you, because singleton means that it has to be created somewhere and each consecutive call to constructor would return it, it serves slight different purpose

Comment thread packages/react-native-audio-api/common/cpp/audioapi/core/inputs/AudioRecorder.h Outdated
// service unwind — otherwise it runs forever.
activeNotifications[key] = false
ForegroundServiceManager.unsubscribe(notification)
Log.d(TAG, "Hiding notification: $key (unsubscribed from foreground service)")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it could leave the notification hanging otherwise

@maciejmakowski2003

Copy link
Copy Markdown
Collaborator

claude code review findings - could

Bugs

  1. enableFileOutput can break a recording that's starting (Android and iOS). High.
    AndroidAudioRecorder.cpp:289 and IOSAudioRecorder.mm:509 now check isIdle() before taking fileWriterMutex_. start() runs on the promise worker thread and holds that mutex, and it only sets Recording at the very end. So this can happen:

JS calls start(), then enableFileOutput().
The isIdle() check passes and enableFileOutput waits on the lock.
start() finishes and sets up the writer.
enableFileOutput then sets fileOutputConfigured_ = false in the middle of the session.

After that, usesFileOutput() is false: onAudioReady stops writing, and stop() never moves or closes fileWriter_, so the file is truncated and never finalized. On Android, onErrorAfterClose briefly sets the state to Idle between cleanup() and reopening the stream, which opens the same window.

Fix: do the isIdle() check after taking the lock.
The doc comment above the Android enableFileOutput still says it "will prepare and open the file for writing immediately" when active, which is no longer true.
Returning Ok while ignoring the properties is silent. Either return Err or document it.

  1. ActiveRecorderHandle holds its mutex through recorder->stop(). Medium.
    stop() waits until the file is closed and finalized, and pause/resume hold the same mutex. Callers on the JS thread block on it, so tapping Stop in the notification freezes JS until the file is finalized. Those callers:

the isRecordingOngoing() JSI global
~AudioRecorderHostObject (runs during GC)
setRecorder / clearRecorder in the start/stop promise lambdas

Also, recorder is declared after lock, so it's destroyed while the lock is still held. If the handle's copy is the last owner, the recorder's destructor (stream close, file close) runs under the mutex. clearRecorder avoids this on purpose, but the other methods don't.

Fix: copy the shared_ptr under the lock, unlock, do the work, then relock only to write lastResult_.

  1. An empty slot counts as IDLE, so tapping Pause/Resume hides the notification. Medium.
    RecordingNotificationReceiver.renderNotification(IDLE) calls hideRecordingNotification(), which also unsubscribes the foreground service. The slot is empty in several ordinary cases:

Before start() resolves: setRecorder only runs at the end of the async lambda. If the notification is shown first and the user taps Pause in that window, the notification and the microphone foreground service go away while recording is starting.
After a JS stop(): if the app keeps the notification up, the slot is already cleared.
After GC: the recorder's host object was collected.

Stop on an empty slot also sends RECORDING_NOTIFICATION_STOP to JS even though nothing was stopped. Consider a separate "no recorder" result, and only hide on the stop action.

  1. Having more than one recorder isn't enforced. Low to medium.
    If recorder A is recording and recorder B starts, B takes the slot. When B stops, clearRecorder(B) empties the slot while A is still recording. Then isRecordingOngoing() returns false, iOS setActive(false) deactivates the session under A, and the notification controls do nothing. The header documents "only one may occupy this slot", but nothing stops it happening.

  2. After a JS reload, notification events may go to the old runtime. Low, not verified.
    The receiver keeps the AudioAPIModule it was created with in initializeNotification. AudioAPIModule.invalidate() doesn't reset the hybrid, and AudioEventHandlerRegistry keeps a raw jsi::Runtime*. So in the "fresh JS context resyncs" case this branch targets, pause/resume/stop events likely reach the old module instead of the new one. If the old runtime is gone, that could be a native crash, which the Kotlin try/catch in dispatchEventToJs can't catch. Worth testing by reloading while the notification is visible.

  3. iOS setAudioSessionActivity(false) does nothing during a recording. Low.
    It resolves nil without deactivating, so JS thinks it worked and nothing deactivates the session later. Rejecting, or at least documenting it, would be clearer.

Nits
destructorMutex_ guards all of the handle's state, not just destruction. mutex_ would be a more accurate name.
RecordingStopResult::size and duration are uninitialized. Use double size{} and double duration{}.
options !== state.cachedRNOptions: every JS call creates a new ReadableMap, so this check never skips anything. You can delete cachedRNOptions.

@closetcaiman closetcaiman added the breaking-change Changes that break backward compatibility or require user migration label Sep 16, 2026
@mdydek
mdydek force-pushed the feat/android-fgs-survive-task-removal branch from 2484feb to 6d75b49 Compare September 17, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android Native Android implementation, C++/Java/Kotlin bindings, or Android-specific issues breaking-change Changes that break backward compatibility or require user migration feature New user-facing features or major capabilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants