Skip to content

fix(android): finalize the active recording when the foreground service is destroyed by the system - #1321

Open
wildseansy wants to merge 2 commits into
software-mansion:mainfrom
wildseansy:fix/android-finalize-recording-on-service-destroy
Open

wildseansy wants to merge 2 commits into
software-mansion:mainfrom
wildseansy:fix/android-finalize-recording-on-service-destroy

Conversation

@wildseansy

Copy link
Copy Markdown
Contributor

Closes # — no existing issue tracks this.

⚠️ Breaking changes ⚠️

  • None. Behaviour changes only when the system destroys CentralizedForegroundService while a recording is active.

Introduced changes

  • Problem: with android:stopWithTask="true" (the Expo plugin default) swiping the app out of recents makes the system destroy CentralizedForegroundService directly: onTaskRemoved is never delivered, only onDestroy runs, and the process may be frozen as soon as it returns. onDestroy currently only calls ForegroundServiceManager.onServiceDestroyed(), which restarts the service while subscribers remain and never touches the recorder, so an in-progress recording is left unfinalized. Observed in production: Android silences the mic input while the process sits in the background, the file accumulates a silent tail, and when the app is reopened capture resumes, leaving a silent gap in the saved recording.
  • Fix: onDestroy now stops the recorder through the existing NativeRecorderControl.stop() JNI entry point (already registered in JNI_OnLoad, safe on an empty slot) and, on a successful stop, hides the recording notification. The hide runs before onServiceDestroyed() so the notification is already unsubscribed when stillWanted is evaluated and a dying process does not try to restart the service.
  • The stop is deliberately synchronous on the calling thread: posting to an executor could leave the finalize unexecuted once the process is frozen. ActiveRecorderHandle holds its own mutex while the recorder's locks are taken and the audio thread only try_to_locks, so this cannot deadlock, and an AAC finalize is a flush and close.
  • The stop is gated on a new explicitStop flag, set only when the library itself ends the service: the ACTION_STOP path once stopSelfResult() succeeds (a superseded stop leaves the service alive), and the placeholder path before its unconditional stopSelf(). Without the gate a JS-initiated hide() while still recording would stop the recorder, because that path also ends with the service being destroyed.
  • Known benign side effect: hideRecordingNotification() → ForegroundServiceManager.unsubscribe → stopServiceIfNotNeeded → context.startService(ACTION_STOP) from inside onDestroy either throws the background-start IllegalStateException (caught and logged by stopForegroundService) or bounces a fresh service instance that takes the explicitStop path and exits immediately.

Checklist

  • Linked relevant issue — none exists
  • Updated relevant documentation — N/A, no API change
  • Added/Conducted relevant tests — not yet on device for this version, hence the draft. The same mechanism (destroy-hook gated on explicitStop) shipped in an earlier fork of this library built on a custom recorder registry; this PR rebuilds it on NativeRecorderControl.stop(). Acceptance matrix I intend to run before marking ready: swipe-kill from recents while recording and while paused → logcat shows Centralized foreground service destroyed with no Service destroyed while … still need it, restarting, notification gone within 2 s, file finalized and playable; notification Stop while the app is alive → clean stop, hook not triggered; JS hide() while recording → recorder keeps running.
  • Performed self-review of the code
  • Updated Web Audio API coverage — N/A
  • Added support for web — N/A, Android-only service
  • Updated old arch android spec file — N/A, no module spec change

Verification so far

  • explicitStop semantics checked in both directions: the only explicit-stop path (ForegroundServiceManager.stopForegroundService) always delivers ACTION_STOP before stopSelf(), so a fresh instance that only ever sees ACTION_STOP skips the hook, while START-then-system-death runs it. Both callbacks run on the main thread and each lifecycle gets a new instance, so instance state is sufficient; START_NOT_STICKY means no null-intent restarts.
  • ktlint 1.7.0 and commitlint (config-conventional) clean.

🤖 Generated with Claude Code

…ce is destroyed by the system

With android:stopWithTask="true" (the Expo plugin default) swiping the app
out of recents makes the system destroy CentralizedForegroundService
directly: onTaskRemoved is never delivered, only onDestroy runs, and the
process may be frozen as soon as it returns. Upstream's onDestroy only
calls ForegroundServiceManager.onServiceDestroyed(), which restarts the
service while subscribers remain and never touches the recorder, so an
in-progress recording was left unfinalized.

onDestroy now stops the recorder through the existing
NativeRecorderControl.stop() JNI entry point (already registered in
JNI_OnLoad and safe on an empty slot) instead of adding new JNI, and on
a successful stop hides the recording notification. The hide runs before
onServiceDestroyed() so the notification is already unsubscribed when
stillWanted is evaluated and a dying process does not try to restart
the service. The stop is deliberately synchronous on the calling thread:
posting to an executor could leave the finalize unexecuted once the
process is frozen, ActiveRecorderHandle holds its own mutex while the
recorder's locks are taken and the audio thread only try_to_lock's, and
an AAC finalize is a flush and close.

The stop is gated on a new explicitStop flag, set only when the library
itself ends the service: the ACTION_STOP path once stopSelfResult()
succeeds (a superseded stop leaves the service alive), and the
placeholder path before its unconditional stopSelf(). Without the gate a
JS-initiated hide() while still recording would stop the recorder,
because that path also ends with the service being destroyed.

Known benign side effect: hideRecordingNotification() ->
ForegroundServiceManager.unsubscribe -> stopServiceIfNotNeeded ->
context.startService(ACTION_STOP) from inside onDestroy either throws
the background-start IllegalStateException (caught and logged by
stopForegroundService) or bounces a fresh service instance that takes
the explicitStop path and exits immediately.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mdydek
mdydek marked this pull request as ready for review September 28, 2026 14:31
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

WPT non-regression comparison

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

Unchanged sections (28)
Spec section Base pass Head pass Delta
Processing model 1 1 0
Other 56 56 0
AnalyserNode 138 138 0
AudioBuffer 140 140 0
AudioBufferSourceNode 220 220 0
AudioContext 58 58 0
AudioNode 261 261 0
AudioParam 680 680 0
BiquadFilterNode 275 275 0
ChannelMergerNode 30 30 0
ChannelSplitterNode 7 7 0
ConstantSourceNode 64 64 0
ConvolverNode 178 178 0
DelayNode 127 127 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 727 727 0
PeriodicWave 33 33 0
ScriptProcessorNode 0 0 0
StereoPannerNode 104 104 0
WaveShaperNode 82 82 0

Baseline: 604a55c525f7ea272b7d007144418557be212213 · Candidate: 358bc6482d941a5212d8f7d4b9a769b40e6d5fe4

Workflow run · this comment is updated on every push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant