Skip to content

Feat/android fgs survive task removal - #1234

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

Feat/android fgs survive task removal#1234
mdydek wants to merge 9 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

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

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

PASS — no regressions · 0 improved section(s) · overall 2632 → 2632 (0)

Unchanged sections (28)
Spec section Base pass Head pass Delta
Processing model 0 0 0
Other 52 52 0
AnalyserNode 138 138 0
AudioBuffer 140 140 0
AudioBufferSourceNode 208 208 0
AudioContext 58 58 0
AudioNode 261 261 0
AudioParam 599 599 0
BiquadFilterNode 275 275 0
ChannelMergerNode 30 30 0
ChannelSplitterNode 7 7 0
ConstantSourceNode 59 59 0
ConvolverNode 170 170 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 75 75 0

Baseline: 3d7c2a023ea00845aaa723121071a8cdf98ba3a0 · Candidate: 4b82edab1834da91e7e33377ec9147be52737460

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
</TabItem>
</Tabs>

### Keeping the recording alive when the app is closed

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.

Is this android specific? Maybe we could add a badge?

assignOnErrorCallbackId(0);
}
void assignOnErrorCallbackId(uint64_t callbackId);
// Defined inline so AudioRecorder.cpp doesn't drag this class's whole

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.

?

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.

🤔

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.

:hurtrealbad:

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.

Is it possible to omit/reduce this trip to gain access to Recorder?

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.

According to https://en.cppreference.com/cpp/memory/weak_ptr/lock, std::weak_ptr::lock() is an atomic operation, therefore it is unnecessary to lock mutex_, when locking recorder_.

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.

Sure, I had in mind the 'trip' between layers (JNI, etc.) which is not feasible here I'm afraid.

@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 on lines +24 to +26
// 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.

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.

Suggested change
// 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.
// Recover from "app disabled" state - recording can survive the app kill (android)

Comment on lines +160 to +161
// The stop action already stopped the recorder natively and hid the notification;
// here we only pick up the resulting files and sync the UI.

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.

Suggested change
// The stop action already stopped the recorder natively and hid the notification;
// here we only pick up the resulting files and sync the UI.

Comment on lines +328 to +329
// 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.

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.

Suggested change
// 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.

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.

might be also worth changing on the native side to handle that and make enable a no-op in that case

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.

agree

Comment on lines +335 to +336
// The recording and its notification intentionally stay alive when leaving this
// screen; they can be stopped from the notification or after coming back.

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.

Suggested change
// The recording and its notification intentionally stay alive when leaving this
// screen; they can be stopped from the notification or after coming back.

Comment on lines +339 to +341
if (!AudioRecorder.isRecordingOngoing()) {
AudioManager.setAudioSessionActivity(false);
}

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.

same here

Comment on lines +81 to +102
/**
* 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;

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.

jsdoc-style

Comment on lines +18 to +23
/**
* 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.
*/

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.

Suggested change
/**
* 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.
*/

Comment on lines +59 to +67
/**
* 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.
*/

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.

Suggested change
/**
* 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.
*/

Comment on lines +72 to +79
/**
* 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.
*/

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.

Suggested change
/**
* 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.
*/

assignOnErrorCallbackId(0);
}
void assignOnErrorCallbackId(uint64_t callbackId);
// Defined inline so AudioRecorder.cpp doesn't drag this class's whole

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.

🤔

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

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.

Suggested change
paused?: boolean; // flag indicating whether to display the pause or the resume action
paused?: boolean;

@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 on lines +328 to +329
// 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.

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.

agree

resumeIconResourceName?: string;
color?: number; //
color?: number;
showStopAction?: boolean; // shows the native stop action, default: false

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.

Suggested change
showStopAction?: boolean; // shows the native stop action, default: false
showStopAction?: boolean;

Comment on lines +313 to +315
// 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.

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.

Suggested change
// 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.
// Collect the files of a recording that was stopped natively while this screen was unmounted.

style={styles.text}
/>
);
return <Text style={styles.text}>{durationString}</Text>;

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 we moved to state driven timer?

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.

.hpp ?

override fun show(options: ReadableMap?): Notification {
initializeNotification()
val context = reactContext.get() ?: throw IllegalStateException("React context is null")
if (options != state.cachedRNOptions) {

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 it is deleted?

Comment on lines +69 to +71
dispatchEventToJs(
if (paused) AudioEvent.RECORDING_NOTIFICATION_PAUSE else AudioEvent.RECORDING_NOTIFICATION_RESUME,
)

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.

it does not look great

* 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())

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.

let's use repo-wide audio API module methods invoking convention

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.

let's rework it to return a state after each method:

RecorderState pauseActiveRecording();
RecorderState resumeActiveRecording();
RecorderState stopActiveRecording();

thanks to that approach the notification can become a pure function of a state the recorder, the notification becomes a pure function of a state the recorder

Comment on lines +123 to +124
* Used by native-initiated flows (e.g. the recording stop action) that don't know
* the JS-chosen key.

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.

Should a callee know their callers?

Suggested change
* Used by native-initiated flows (e.g. the recording stop action) that don't know
* the JS-chosen key.

Comment on lines +137 to +138
* 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 —

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.

Like above

Suggested change
* 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 —
* No-op unless the recording notification is currently visible —

Comment on lines +72 to +73
* Used by native-initiated pause/resume so the action button flips even when JS
* is unreachable.

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.

Suggested change
* Used by native-initiated pause/resume so the action button flips even when JS
* is unreachable.

Comment on lines +34 to +38
std::shared_ptr<AudioRecorder> recorder;
{
std::scoped_lock lock(mutex_);
recorder = recorder_.lock();
}

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.

This fragment may be extracted to a helper function inline std::shared_ptr<AudioRecorder> lockRecorder(), because it is repeated in almost all methods.

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.

///
/// Assumes at most one AudioRecorder is alive at a time; setting a new recorder replaces
/// the previous one.
class ActiveRecorderHandle {

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.

Given it is a singleton, the constructor should be private.

Suggested change
class ActiveRecorderHandle {
class ActiveRecorderHandle {
private:
ActiveRecorderHandle() = default;

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.

According to https://en.cppreference.com/cpp/memory/weak_ptr/lock, std::weak_ptr::lock() is an atomic operation, therefore it is unnecessary to lock mutex_, when locking recorder_.

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
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 feature New user-facing features or major capabilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants