From 45298edfa637df2e655f7e2a39d8fcd2f37cab92 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 12 Jun 2026 14:55:24 +0200 Subject: [PATCH] fix(android): suspend idle AudioContext in background to stop battery drain (#8243) (#8280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(android): suspend idle AudioContext on background (#8243) The singleton Web Audio AudioContext is resumed on the first user gesture (unlockAudioContext) and was never suspended or closed afterwards. On Android a running AudioContext keeps an AudioTrack open in the audio HAL even while completely silent, which drains the battery continuously and keeps the process alive in the background. A reporter's batterystats dump showed 1364 mAh (~54% of total discharge) from 18h of continuous 'audio (running)' with no sound actually playing. Suspend the context when the app is backgrounded (appStateChange) so the output stream is released; playback lazily resumes it via ensureAudioContextRunning(). A keep-awake flag set by the focus-mode white noise prevents cutting off an intentionally-playing background sound. * fix(android): release AudioContext when white noise stops (#8243) Address multi-review finding: suspendAudioContext() previously only fired on the appStateChange background edge, but keepAwake is cleared later by stopWhiteNoise(). A focus session with white noise kept alive by the foreground service can end while backgrounded, leaving the context running and silent with no further background edge to suspend it — reintroducing the drain until the next foreground/background cycle. stopWhiteNoise() now releases the output stream once the only long-lived sound ends; the next playback lazily resumes the context. Also rename the keep-awake param for readability and document the single-owner assumption. * fix(android): don't suspend AudioContext in stopWhiteNoise (#8243) Fable-review finding: suspending in stopWhiteNoise() races the session-done chime. stopWhiteNoise() runs via the whiteNoiseSound$ selector effect the moment a session completes, around when notifyOnSessionComplete$ plays the chime on the shared context; since suspend() flips state asynchronously, the chime can start into a context that suspends ms later and gets cut off — an audible regression on every white-noise session completion. Back out the stopWhiteNoise suspend (added in the previous commit). The residual it targeted (context left running after white noise ends while backgrounded) is largely unreachable on Android anyway: the focus timer's tick pauses in the background, so a session can't complete while backgrounded. The keep-awake flag, the appStateChange background suspend, and the polish (param rename, single-owner doc, close-reset test) are kept. --- src/app/util/audio-context.spec.ts | 92 ++++++++++++++++++++++++++++++ src/app/util/audio-context.ts | 36 ++++++++++++ src/app/util/white-noise.ts | 10 +++- src/main.ts | 8 ++- 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/app/util/audio-context.spec.ts b/src/app/util/audio-context.spec.ts index a94b7c2143..5b3359b149 100644 --- a/src/app/util/audio-context.spec.ts +++ b/src/app/util/audio-context.spec.ts @@ -6,6 +6,8 @@ import { unlockAudioContext, ensureAudioContextRunning, playBuffer, + suspendAudioContext, + setAudioContextKeepAwake, } from './audio-context'; describe('audio-context', () => { @@ -351,6 +353,96 @@ describe('audio-context', () => { }); }); + describe('suspendAudioContext', () => { + const mockRunningContext = (): { suspend: jasmine.Spy } => { + const suspend = jasmine.createSpy('suspend').and.returnValue(Promise.resolve()); + const mockContext = { + state: 'running', + resume: jasmine.createSpy('resume').and.returnValue(Promise.resolve()), + suspend, + close: jasmine.createSpy('close'), + }; + (window as any).AudioContext = jasmine + .createSpy('AudioContext') + .and.returnValue(mockContext); + return { suspend }; + }; + + it('should suspend a running context', () => { + const { suspend } = mockRunningContext(); + getAudioContext(); + + suspendAudioContext(); + + expect(suspend).toHaveBeenCalled(); + }); + + it('should be a no-op when no context exists yet', () => { + const { suspend } = mockRunningContext(); + + // Never call getAudioContext() — nothing should be created or suspended. + suspendAudioContext(); + + expect((window as any).AudioContext).not.toHaveBeenCalled(); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should not suspend a context that is already suspended', () => { + const suspend = jasmine.createSpy('suspend').and.returnValue(Promise.resolve()); + const mockContext = { + state: 'suspended', + resume: jasmine.createSpy('resume').and.returnValue(Promise.resolve()), + suspend, + close: jasmine.createSpy('close'), + }; + (window as any).AudioContext = jasmine + .createSpy('AudioContext') + .and.returnValue(mockContext); + getAudioContext(); + + suspendAudioContext(); + + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should not suspend while keep-awake is set (e.g. white noise playing)', () => { + const { suspend } = mockRunningContext(); + getAudioContext(); + setAudioContextKeepAwake(true); + + suspendAudioContext(); + + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should suspend again after keep-awake is cleared', () => { + const { suspend } = mockRunningContext(); + getAudioContext(); + setAudioContextKeepAwake(true); + suspendAudioContext(); + expect(suspend).not.toHaveBeenCalled(); + + setAudioContextKeepAwake(false); + suspendAudioContext(); + + expect(suspend).toHaveBeenCalled(); + }); + + it('should re-allow suspend after closeAudioContext resets keep-awake', () => { + mockRunningContext(); + getAudioContext(); + setAudioContextKeepAwake(true); + closeAudioContext(); + + // Fresh running context after teardown; keep-awake must no longer block. + const { suspend } = mockRunningContext(); + getAudioContext(); + suspendAudioContext(); + + expect(suspend).toHaveBeenCalled(); + }); + }); + describe('closeAudioContext', () => { it('should close the context and clear cache', () => { const mockContext = { diff --git a/src/app/util/audio-context.ts b/src/app/util/audio-context.ts index ffd831d66a..2c1b9579f8 100644 --- a/src/app/util/audio-context.ts +++ b/src/app/util/audio-context.ts @@ -6,6 +6,10 @@ let audioContext: AudioContext | null = null; const audioBufferCache = new Map(); let unlocked = false; +// Whether something still needs the context running while the app is backgrounded +// (e.g. focus-mode white noise). While set, suspendAudioContext() is a no-op so +// the ongoing sound is not cut off. See setAudioContextKeepAwake(). +let keepAwake = false; /** * Returns the singleton AudioContext instance, creating it if necessary. @@ -93,6 +97,37 @@ export const playBuffer = async ( source.start(0); }; +/** + * Marks (or clears) a need to keep the AudioContext running even while the app is + * backgrounded. Used by long-lived background audio such as the focus-mode white + * noise so that suspendAudioContext() does not cut it off mid-playback. + * + * Single-owner: white noise is currently the only caller. If a second long-lived + * background sound is ever added, replace this boolean with a reference count so + * one source clearing the flag cannot release another's. + */ +export const setAudioContextKeepAwake = (keep: boolean): void => { + keepAwake = keep; +}; + +/** + * Suspends the singleton AudioContext to release the underlying audio output + * stream. On Android a `running` AudioContext keeps an AudioTrack open in the + * audio HAL even while completely silent — which drains the battery around the + * clock and keeps the process alive in the background (issue #8243). Suspending + * releases that stream; the next playback resumes it via ensureAudioContextRunning(). + * + * No-op when the context was never created, is already suspended, or something + * still needs it running (see setAudioContextKeepAwake). + */ +export const suspendAudioContext = (): void => { + if (!keepAwake && audioContext && audioContext.state === 'running') { + // suspend() returns a promise; the release begins immediately so we don't + // await it. Swallow errors — the context may already be closing. + void audioContext.suspend().catch(() => {}); + } +}; + /** * Registers one-time touchend/click listeners that create and resume the AudioContext * on the first user gesture. This is required on iOS where AudioContext can only be @@ -137,4 +172,5 @@ export const closeAudioContext = (): void => { } audioBufferCache.clear(); unlocked = false; + keepAwake = false; }; diff --git a/src/app/util/white-noise.ts b/src/app/util/white-noise.ts index b1a30f15fc..2c9230a47d 100644 --- a/src/app/util/white-noise.ts +++ b/src/app/util/white-noise.ts @@ -1,4 +1,4 @@ -import { ensureAudioContextRunning } from './audio-context'; +import { ensureAudioContextRunning, setAudioContextKeepAwake } from './audio-context'; let activeSource: AudioBufferSourceNode | null = null; let activeGain: GainNode | null = null; @@ -40,10 +40,13 @@ export const startWhiteNoise = async (volume: number): Promise => { source.start(0); activeSource = source; activeGain = gain; + // Keep the context running while backgrounded so the focus sound keeps playing. + setAudioContextKeepAwake(true); }; export const stopWhiteNoise = (): void => { startCancelled = true; + setAudioContextKeepAwake(false); if (activeSource) { try { activeSource.stop(); @@ -57,4 +60,9 @@ export const stopWhiteNoise = (): void => { activeGain.disconnect(); activeGain = null; } + // NOTE: intentionally does NOT suspend the AudioContext here. stopWhiteNoise() + // runs (via the whiteNoiseSound$ selector effect) right as a session completes, + // racing the session-done chime (playSound) on the shared context — suspending + // would cut the chime off. The context is released instead by the appStateChange + // background handler in main.ts (#8243). }; diff --git a/src/main.ts b/src/main.ts index ff989cd17e..e95c13ef29 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,7 +91,7 @@ import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe'; import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service'; import { CustomDateAdapter } from './app/core/date-time-format/custom-date-adapter'; import { TranslateMatDatepickerIntl } from './app/core/date-time-format/translate-mat-datepicker-intl'; -import { unlockAudioContext } from './app/util/audio-context'; +import { suspendAudioContext, unlockAudioContext } from './app/util/audio-context'; import { NetworkRetryInterceptorService } from './app/core/http/network-retry-interceptor.service'; if (environment.production || environment.stage) { @@ -488,6 +488,9 @@ if (IS_ANDROID_WEB_VIEW) { if (isActive) { return; } + // Release the audio output stream so a silent-but-running AudioContext does + // not keep the audio hardware (and the process) awake in the background (#8243). + suspendAudioContext(); const taskId = await BackgroundTask.beforeExit(async () => { try { await flushPendingOperations('Android'); @@ -505,6 +508,9 @@ if (IS_IOS_NATIVE) { if (isActive) { return; } + // Release the audio output stream so a silent-but-running AudioContext does + // not keep the audio hardware (and the process) awake in the background (#8243). + suspendAudioContext(); const taskId = await BackgroundTask.beforeExit(async () => { try { // Dispatch any accumulated tracked time so it is enqueued before the