From 643c76d0279f65aca20bc160198608a4f7ddba5f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:37:55 -0700 Subject: [PATCH] Make keep-awake acquisition best-effort with a bounded startup timeout - Recording start no longer blocks (or fails) on keep-awake acquisition: a hung or failing native call is capped at a short budget and logged instead of delaying or aborting dictation. - Add native-call timeouts, orphan-tag tracking, and reacquire/drain logic in mobile-dictation-keep-awake.ts so Activity recreation on Android and stale tags no longer wedge the keep-awake queue. - Add useMobileDictationForegroundKeepAwake to refresh the wake tag on Android foreground and retry failed refreshes/deactivations. - Hold the wake tag through chunk drain and the finish RPC so a screen lock can't suspend the app before the transcript arrives, and keep cleanup running even if native recording shutdown throws. - Loosen expo-keep-awake to a caret range to unblock the patch pulling in these native fixes. --- mobile/package.json | 2 +- mobile/pnpm-lock.yaml | 2 +- .../mobile-dictation-desktop-start.test.ts | 55 +-- .../hooks/mobile-dictation-desktop-start.ts | 40 +- .../mobile-dictation-foreground-keep-awake.ts | 62 ++++ .../hooks/mobile-dictation-keep-awake.test.ts | 341 +++++++++++++++++- .../src/hooks/mobile-dictation-keep-awake.ts | 176 ++++++++- .../hooks/mobile-dictation-session-state.ts | 4 + .../hooks/use-mobile-dictation-source.test.ts | 91 +++-- mobile/src/hooks/use-mobile-dictation.ts | 19 +- 10 files changed, 703 insertions(+), 89 deletions(-) create mode 100644 mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts diff --git a/mobile/package.json b/mobile/package.json index 20aa2fa19a5..cbbc8c86bed 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -35,7 +35,7 @@ "expo-haptics": "^55.0.14", "expo-image-manipulator": "^55.0.17", "expo-image-picker": "^55.0.20", - "expo-keep-awake": "55.0.8", + "expo-keep-awake": "~55.0.8", "expo-linking": "^55.0.15", "expo-modules-core": "~55.0.25", "expo-network": "~55.0.14", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 824ffa7d7bb..aafe638813f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -66,7 +66,7 @@ importers: specifier: ^55.0.20 version: 55.0.20(expo@55.0.27) expo-keep-awake: - specifier: 55.0.8 + specifier: ~55.0.8 version: 55.0.8(expo@55.0.27)(react@19.2.6) expo-linking: specifier: ^55.0.15 diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts index eb7f21e726d..d32f123b37d 100644 --- a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state' import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' import type { RpcClient } from '../transport/rpc-client' @@ -93,42 +94,44 @@ describe('startMobileDictationDesktopSession', () => { expect(harness.commitRecordingStart).not.toHaveBeenCalled() }) - it('swallows a keep-awake failure if cleanup is superseded by a newer start', async () => { - let setNewerStart = () => undefined - const harness = createStartHarness({ - acquire: async () => { - throw new Error('Keep awake failed') - }, - sendRequest: async (method) => { - if (method === 'speech.dictation.cancel') { - setNewerStart() - } - return OK_RESPONSE - } - }) - setNewerStart = harness.setNewerStart + it('does not hold recording start on a hung keep-awake acquisition', async () => { + vi.useFakeTimers() + try { + const harness = createStartHarness({ + acquire: () => new Promise(() => undefined) + }) - await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) - expect(harness.setIdle).not.toHaveBeenCalled() - expect(harness.getActiveId()).toBe('dictation-b') - expect(harness.commitRecordingStart).not.toHaveBeenCalled() + await expect(startPromise).resolves.toBe(true) + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.release).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) - it('still reports a keep-awake failure for the current start', async () => { + it('continues dictation when keep-awake acquisition fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const harness = createStartHarness({ acquire: async () => { - throw new Error('Keep awake failed') + throw new Error('Unable to activate keep awake') } }) - await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( - 'Keep awake failed' - ) + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) - expect(harness.setIdle).toHaveBeenCalledOnce() - expect(harness.getActiveId()).toBeNull() - expect(harness.commitRecordingStart).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledOnce() + consoleError.mockRestore() + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-a') + expect(harness.release).not.toHaveBeenCalled() + expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) }) it('does not surface a desktop-start failure after the start became stale', async () => { diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts index 1d8eda8b1fa..b3c093c526f 100644 --- a/mobile/src/hooks/mobile-dictation-desktop-start.ts +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -1,4 +1,7 @@ -import { isCurrentMobileDictationStart } from './mobile-dictation-session-state' +import { + MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS, + isCurrentMobileDictationStart +} from './mobile-dictation-session-state' import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' import type { RpcClient } from '../transport/rpc-client' @@ -67,24 +70,23 @@ export async function startMobileDictationDesktopSession( return false } - try { - // Keep-awake is acquired only after the desktop session exists, so stale - // mobile starts can be canceled without holding a screen-lock tag. - await keepAwakeOwner.acquire(dictationId) - } catch (err) { - if (!isCurrentStart(options)) { - setIdleIfGenerationCurrent(options) - return false - } - options.clearActiveId(dictationId) - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - const shouldReport = canReportStartFailure(options) - setIdleIfGenerationCurrent(options) - if (!shouldReport) { - return false - } - throw err - } + // Keep-awake is acquired only after the desktop session exists, so stale + // mobile starts can be canceled without holding a screen-lock tag. It is + // best-effort: Android throws with no current Activity, and a screen-lock + // nicety must not abort an otherwise viable dictation — nor delay recording + // past a small budget when native calls hang. A late acquisition finishes in + // the background; the serialized keep-awake queue orders any later release + // after it. + await new Promise((resolve) => { + const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + keepAwakeOwner + .acquire(dictationId) + .catch((err: unknown) => console.error('Keep-awake activation failed', err)) + .finally(() => { + clearTimeout(budgetTimer) + resolve() + }) + }) if (!isCurrentStart(options)) { await keepAwakeOwner.release(dictationId).catch(() => undefined) diff --git a/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts new file mode 100644 index 00000000000..a229c0d6149 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react' +import { AppState, Platform } from 'react-native' +import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake' +import type { RefObject } from 'react' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' + +// A transient Activity gap can fail a foreground refresh; retry briefly while +// the same dictation is live instead of waiting for the next foreground. +const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000] + +let globalStaleTagDrainInstalled = false + +// Failed final deactivations must be retried even after every session screen +// unmounts, or a stale native tag keeps the screen awake until app restart. +// Installed once for the app's lifetime; the drain spares still-wanted tags +// and fast-paths to a no-op when nothing is pending. +function installGlobalStaleTagForegroundDrain(): void { + if (globalStaleTagDrainInstalled) { + return + } + globalStaleTagDrainInstalled = true + AppState.addEventListener('change', (state) => { + if (state === 'active') { + void drainMobileDictationKeepAwakeCleanup().catch(() => undefined) + } + }) +} + +export function useMobileDictationForegroundKeepAwake( + keepAwakeOwner: MobileDictationKeepAwakeOwner, + activeIdRef: RefObject +): void { + useEffect(() => { + installGlobalStaleTagForegroundDrain() + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity + // recreation silently drops it mid-dictation; refresh on return to + // active. iOS re-applies natively on foreground. + if (Platform.OS !== 'android') { + return + } + const reacquireWithRetry = (dictationId: string, attempt: number): void => { + void keepAwakeOwner.reacquire(dictationId).catch(() => { + const delay = REACQUIRE_RETRY_DELAYS_MS[attempt] + if (delay === undefined) { + return + } + setTimeout(() => { + if (activeIdRef.current === dictationId) { + reacquireWithRetry(dictationId, attempt + 1) + } + }, delay) + }) + } + const sub = AppState.addEventListener('change', (state) => { + const dictationId = activeIdRef.current + if (state === 'active' && dictationId) { + reacquireWithRetry(dictationId, 0) + } + }) + return () => sub.remove() + }, [keepAwakeOwner, activeIdRef]) +} diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts index 05fe4351b42..362f3a0cf68 100644 --- a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts +++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts @@ -10,7 +10,11 @@ vi.mock('expo-keep-awake', () => ({ deactivateKeepAwake: keepAwake.deactivate })) -import { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import { + MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, + MobileDictationKeepAwakeOwner, + drainMobileDictationKeepAwakeCleanup +} from './mobile-dictation-keep-awake' function deferred(): { promise: Promise @@ -84,7 +88,7 @@ describe('MobileDictationKeepAwakeOwner', () => { keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise) const releaseFirst = firstOwner.release('first') - await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) expect(keepAwake.deactivate).toHaveBeenCalledOnce() const replacementOwner = new MobileDictationKeepAwakeOwner() @@ -102,4 +106,337 @@ describe('MobileDictationKeepAwakeOwner', () => { ) await replacementOwner.release('replacement') }) + + it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + + // Both the release deactivate and its trailing drain retry fail. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + const replacementOwner = new MobileDictationKeepAwakeOwner() + await expect(replacementOwner.acquire('second')).resolves.toBeUndefined() + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + // The still-pending first tag drains once a deactivation finally succeeds. + await replacementOwner.release('second') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength( + 4 + ) + }) + + it('times out a never-settling native call instead of wedging the queue', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const hungOwner = new MobileDictationKeepAwakeOwner() + const hungAcquire = hungOwner.acquire('hung') + // Drain microtasks to quiescence so the timeout timer is registered. + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out') + + // The queue must advance, and another owner's drain must spare the + // still-wanted maybe-late activation. + const nextOwner = new MobileDictationKeepAwakeOwner() + await nextOwner.acquire('next') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next') + + // Once its own dictation ends, the orphan gets cleaned. + await hungOwner.release('hung') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength( + 1 + ) + await nextOwner.release('next') + } finally { + vi.useRealTimers() + } + }) + + it('adopts a timed-out activation that lands late while the dictation is live', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('late') + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted, not deactivated: protection stays on for the live dictation. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('late') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength( + 1 + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not let another owner drain a still-wanted timed-out activation', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const ownerA = new MobileDictationKeepAwakeOwner() + const acquireA = ownerA.acquire('wanted') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquireA).rejects.toThrow('Keep-awake native call timed out') + + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted for owner A; released like a normal activation afterwards. + await ownerA.release('wanted') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted')) + ).toHaveLength(1) + await ownerB.release('other') + } finally { + vi.useRealTimers() + } + }) + + it('deactivates a late-landing activation once its dictation has ended', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('ended') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.release('ended') + lateActivation.resolve() + await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)) + expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('retries a timed-out final deactivation via the foreground drain', async () => { + vi.useFakeTimers() + try { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('final') + const tag = keepAwake.activate.mock.calls[0]?.[0] + // The release deactivate times out and its trailing drain retry fails. + keepAwake.deactivate + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockRejectedValueOnce(new Error('Activity unavailable')) + const release = owner.release('final') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(release).rejects.toThrow('Keep-awake native call timed out') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + + await drainMobileDictationKeepAwakeCleanup() + expect(keepAwake.deactivate).toHaveBeenCalledTimes(3) + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + } finally { + vi.useRealTimers() + } + }) + + it('drains orphaned tags on release, not only on the next acquire', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('orphan') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + // The dictation ends without another acquire; release must still clean. + await owner.release('orphan') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan')) + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + it('recovers on foreground reacquire after a failed initial acquisition', async () => { + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + const owner = new MobileDictationKeepAwakeOwner() + await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.reacquire('current') + const tag = keepAwake.activate.mock.calls[1]?.[0] + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(tag).toContain(':current') + // A definite rejection activated nothing, so recovery must not deactivate. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('recovers keep-awake on a later reacquire after a failed refresh', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + // Refresh loses the activation: deactivate succeeds, activate rejects. + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('current') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag) + // Only the pre-refresh deactivate ran; recovery must not deactivate again. + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('records new-dictation intent even when previous-tag cleanup fails', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('first') + + // Release and its trailing drain both fail; the owner keeps stale intent. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(owner.release('first')).rejects.toThrow('Activity unavailable') + + // The next acquire's drain and previous-tag cleanup fail too, and the new + // activation itself fails — intent must still be recorded for the heal. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('second') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second') + + await owner.release('second') + }) + + it('keeps tracking a newer timed-out activation when an older one settles late', async () => { + vi.useFakeTimers() + try { + const first = deferred() + keepAwake.activate.mockImplementationOnce(() => first.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + const second = deferred() + keepAwake.activate.mockImplementationOnce(() => second.promise) + const reacquire = owner.reacquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(reacquire).rejects.toThrow('Keep-awake native call timed out') + + // The older activation settles late with a rejection; the newer + // activation's tracking must survive it. + first.reject(new Error('Activity unavailable')) + await vi.advanceTimersByTimeAsync(0) + + await owner.release('stacked') + // Exactly two: the reacquire's deactivate-first pass, plus the release + // drain cleaning the newer entry the stale settle must not have deleted. + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked')) + ).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('reacquires a maybe-active timed-out activation by deactivating first', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('maybe') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.reacquire('maybe') + // The ambiguous activation may be natively live; it must be deactivated + // before the fresh activate so Android re-applies the window flag. + const tag = keepAwake.activate.mock.calls[0]?.[0] + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.release('maybe') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => { + const ownerA = new MobileDictationKeepAwakeOwner() + await ownerA.acquire('live') + const liveTag = keepAwake.activate.mock.calls[0]?.[0] + + // Foreground refresh: the deactivate leg fails; the tag stays native-on + // and the failure surfaces so the caller's bounded retry can kick in. + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // Another owner's drain must spare the still-wanted live tag. + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // The next foreground retries the full deactivate-then-activate refresh. + await ownerA.reacquire('live') + expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2) + + await ownerA.release('live') + await ownerB.release('other') + }) + + it('reacquires by deactivating before activating so Android re-applies the window flag', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + await owner.reacquire('current') + + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.reacquire('other') + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + await owner.release('current') + }) }) diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts index 5ad807bb238..f94e3fb785a 100644 --- a/mobile/src/hooks/mobile-dictation-keep-awake.ts +++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts @@ -2,10 +2,17 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation' +// Native keep-awake promises can be lost during Activity teardown; a bounded +// wait keeps the serialized queue below from wedging dictation until restart. +export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000 + let nextOwnerId = 0 let keepAwakeOperation: Promise = Promise.resolve() const activeTags = new Set() const pendingCleanupTags = new Set() +// Timed-out activations that may still land natively, keyed to a predicate +// saying whether their dictation still wants the tag. +const pendingActivations = new Map boolean>() function createOwnerId(): string { nextOwnerId += 1 @@ -18,9 +25,82 @@ function enqueueKeepAwakeOperation(action: () => Promise): Promise { return operation } +const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError' + +function isNativeCallTimeout(err: unknown): boolean { + return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME +} + +function withNativeCallTimeout(nativeCall: Promise): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const timeoutError = new Error('Keep-awake native call timed out') + timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME + reject(timeoutError) + }, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + nativeCall.then( + () => { + clearTimeout(timer) + resolve() + }, + (err: unknown) => { + clearTimeout(timer) + reject(err instanceof Error ? err : new Error(String(err))) + } + ) + }) +} + +async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise { + const nativeActivation = activateKeepAwakeAsync(tag) + try { + await withNativeCallTimeout(nativeActivation) + } catch (err) { + // Only a timeout is ambiguous: the activation can still take effect late. + // A definite native rejection activated nothing and needs no cleanup. + if (isNativeCallTimeout(err)) { + // Tracked apart from deactivation retries so drains (including another + // owner's) never turn off an activation its dictation still wants. + pendingActivations.set(tag, isStillWanted) + nativeActivation.then( + () => + void enqueueKeepAwakeOperation(async () => { + // Delete only this activation's own entry — a newer timed-out + // activation of the same tag may have replaced it. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + if (activeTags.has(tag)) { + return + } + if (isStillWanted()) { + // The activation landed late but its dictation is still live; + // adopt it rather than turning off screen-lock protection. + activeTags.add(tag) + return + } + // No owner wants it anymore — the screen must not stay awake. + await deactivateTrackedTag(tag).catch(() => undefined) + }), + () => { + // A late definite rejection means nothing activated after all, but + // spare a newer activation's entry keyed to the same tag. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + } + ) + } + throw err + } + activeTags.add(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + async function deactivateTrackedTag(tag: string): Promise { try { - await deactivateKeepAwake(tag) + await withNativeCallTimeout(deactivateKeepAwake(tag)) } catch (err) { // A replacement hook must be able to retry cleanup after Android replaces // an Activity and the owner that acquired this tag has unmounted. @@ -29,12 +109,28 @@ async function deactivateTrackedTag(tag: string): Promise { } activeTags.delete(tag) pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) } async function cleanupPendingTags(): Promise { - for (const tag of Array.from(pendingCleanupTags)) { - await deactivateTrackedTag(tag) + const staleTags = new Set(pendingCleanupTags) + for (const [tag, isStillWanted] of pendingActivations) { + // A still-wanted timed-out activation is not an orphan: deactivating it + // would turn off screen-lock protection for a live dictation. + if (!isStillWanted()) { + pendingActivations.delete(tag) + staleTags.add(tag) + } } + if (staleTags.size === 0) { + return + } + // Retry concurrently so N stale tags cost one timeout window, not N, and + // swallow failures: a stale tag that still cannot be deactivated must not + // fail the fresh acquire that triggered this retry; it stays queued. + await Promise.allSettled( + Array.from(staleTags, (tag) => deactivateTrackedTag(tag).catch(() => undefined)) + ) } export class MobileDictationKeepAwakeOwner { @@ -53,28 +149,74 @@ export class MobileDictationKeepAwakeOwner { } if (this.acquiredTag) { const previousTag = this.acquiredTag - await deactivateTrackedTag(previousTag) this.acquiredTag = null + // Best-effort: a failed previous-tag cleanup is queued for retry and + // must not block recording intent for the new dictation below. + await deactivateTrackedTag(previousTag).catch(() => undefined) } - await activateKeepAwakeAsync(tag) - activeTags.add(tag) + // Record ownership before the native call: acquiredTag is intent while + // activeTags is native state, so a failed initial activation can still + // be healed by a later foreground reacquire. this.acquiredTag = tag + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated + // Activity silently loses it while native tags persist — and native activate + // skips re-applying the flag while any tag remains, so deactivate first. + reacquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag !== tag) { + return + } + // A timed-out activation may be natively active too, and Android only + // re-applies the window flag from an empty tag set — deactivate both. + if (activeTags.has(tag) || pendingActivations.has(tag)) { + try { + await deactivateTrackedTag(tag) + } catch (err) { + // A still-live tag must not sit in the orphan pool where another + // owner's drain would turn it off without reactivating; keep it in + // the wanted pool and surface the failure so the caller can retry + // before the next foreground event. + pendingCleanupTags.delete(tag) + pendingActivations.set(tag, () => this.acquiredTag === tag) + throw err + } + } + // Also recovers an activation lost to an earlier native failure, so a + // later foreground event can restore keep-awake instead of no-oping. + // Known gap: if another expo-keep-awake owner exists (e.g. dev-build + // dev tools), the native module never empties its tag set, so the + // deactivate/activate cycle cannot re-apply the Android window flag. + await activateTrackedTag(tag, () => this.acquiredTag === tag) }) } release(dictationId?: string): Promise { const targetTag = dictationId ? this.createTag(dictationId) : null return enqueueKeepAwakeOperation(async () => { - const tag = this.acquiredTag - if (!tag || (targetTag && tag !== targetTag)) { - return - } - if (!activeTags.has(tag)) { + try { + const tag = this.acquiredTag + if (!tag || (targetTag && tag !== targetTag)) { + return + } + if (!activeTags.has(tag)) { + this.acquiredTag = null + return + } + await deactivateTrackedTag(tag) this.acquiredTag = null - return + } finally { + // Drain after the owner-local unset so this owner's own timed-out + // activation is no longer wanted and gets cleaned here — an acquire + // may never happen again this session. Still-wanted tags of other + // live dictations are spared by the drain itself. + await cleanupPendingTags() } - await deactivateTrackedTag(tag) - this.acquiredTag = null }) } @@ -86,3 +228,9 @@ export class MobileDictationKeepAwakeOwner { export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner { return new MobileDictationKeepAwakeOwner() } + +// Foreground is the retry point for wake tags whose final deactivation timed +// out after a dictation ended — otherwise nothing runs until the next one. +export function drainMobileDictationKeepAwakeCleanup(): Promise { + return enqueueKeepAwakeOperation(cleanupPendingTags) +} diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts index 8770624610d..bfc2dbd8d29 100644 --- a/mobile/src/hooks/mobile-dictation-session-state.ts +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -26,6 +26,10 @@ export type UseMobileDictationResult = { export const DICTATION_FINISH_TIMEOUT_MS = 75_000 +// Recording start waits at most this long for the best-effort wake tag; a +// slow or hung native keep-awake module must not hold the mic in 'starting'. +export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500 + export function bytesToBase64(bytes: Uint8Array): string { return Buffer.from(bytes).toString('base64') } diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts index 85e8270f021..e6819715112 100644 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -18,6 +18,10 @@ const sessionStateSource = readFileSync( new URL('./mobile-dictation-session-state.ts', import.meta.url), 'utf8' ) +const foregroundKeepAwakeSource = readFileSync( + new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url), + 'utf8' +) function sliceSource(sourceText: string, startPattern: string, endPattern: string): string { const start = sourceText.indexOf(startPattern) @@ -87,8 +91,8 @@ describe('useMobileDictation source invariants', () => { }) it('keeps mobile dictation keep-awake ownership beside the hook', () => { - expect(source).toContain( - "import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'" + expect(source).toMatch( + /import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/ ) expect(source).toContain( 'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])' @@ -107,7 +111,7 @@ describe('useMobileDictation source invariants', () => { const desktopStartIndex = startBody.indexOf( "client.sendRequest('speech.dictation.start', { dictationId })" ) - const acquireIndex = startBody.indexOf('await keepAwakeOwner.acquire(dictationId)') + const acquireIndex = startBody.indexOf('.acquire(dictationId)') const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession') const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)') @@ -133,7 +137,7 @@ describe('useMobileDictation source invariants', () => { 'export async function startMobileDictationDesktopSession', ' return true' ) - const acquireIndex = startBody.indexOf('await keepAwakeOwner.acquire(dictationId)') + const acquireIndex = startBody.indexOf('.acquire(dictationId)') const returnStartedIndex = startBody.indexOf('return true') const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex) @@ -147,25 +151,20 @@ describe('useMobileDictation source invariants', () => { expect(afterAcquire).toContain("client.sendRequest('speech.dictation.cancel', { dictationId })") }) - it('only reports keep-awake acquisition failures for the current start', () => { + it('treats keep-awake acquisition as best-effort for the desktop session', () => { const startBody = sliceDesktopStartBetween( 'export async function startMobileDictationDesktopSession', ' return true' ) - const acquireIndex = startBody.indexOf('await keepAwakeOwner.acquire(dictationId)') - const catchIndex = startBody.indexOf('} catch (err)', acquireIndex) - const staleCleanupIndex = startBody.indexOf('await keepAwakeOwner.release', catchIndex) - expect(catchIndex).toBeGreaterThan(acquireIndex) - expect(staleCleanupIndex).toBeGreaterThan(catchIndex) - const acquireFailurePath = startBody.slice(catchIndex, staleCleanupIndex) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + expect(acquireIndex).toBeGreaterThanOrEqual(0) + const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex) + expect(staleCheckIndex).toBeGreaterThan(acquireIndex) - expect(acquireFailurePath).toContain('catch (err)') - expect(acquireFailurePath).toContain('isCurrentStart(options)') - expect(acquireFailurePath).toContain('return') - expect(acquireFailurePath).toContain('options.clearActiveId(dictationId)') - expect(acquireFailurePath).toContain('setIdleIfGenerationCurrent(options)') - expect(acquireFailurePath).toContain('throw err') - expect(acquireFailurePath).not.toContain('keepAwakeOwner.release') + // An acquisition failure must not cancel the dictation or surface an error. + const acquireChain = startBody.slice(acquireIndex, staleCheckIndex) + expect(acquireChain).toContain('.catch(') + expect(acquireChain).not.toContain('throw') }) it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => { @@ -190,14 +189,60 @@ describe('useMobileDictation source invariants', () => { const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan( - stopBody.indexOf('void keepAwakeOwner.release') - ) - expect(stopBody.indexOf('void keepAwakeOwner.release')).toBeLessThan( stopBody.indexOf('await Promise.allSettled') ) - expect(stopBody.indexOf('void keepAwakeOwner.release')).toBeLessThan( - stopBody.indexOf('speech.dictation.finish') + // The wake tag must be held through chunk drain and the finish RPC so a + // screen lock cannot suspend the app before the transcript arrives. + expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') ) + expect(stopBody.indexOf('} finally {')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + }) + + it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => { + expect(source).toContain('useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)') + expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'") + expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)') + // A transiently failing refresh retries while the dictation is live. + expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]') + expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId') + // Stale-tag retries survive hook unmount via a module-level listener. + expect(foregroundKeepAwakeSource).toContain('installGlobalStaleTagForegroundDrain()') + expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()') + + // Native activate skips re-applying the window flag while any tag remains, + // so reacquire must deactivate before activating. + const reacquireBody = sliceSource( + keepAwakeSource, + 'reacquire(dictationId: string)', + 'release(dictationId?: string)' + ) + expect(reacquireBody.indexOf('await activateTrackedTag(tag,')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan( + reacquireBody.indexOf('await activateTrackedTag(tag,') + ) + }) + + it('keeps cleanup going when native recording shutdown throws', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + const toggleIndex = closeAudio.indexOf('toggleRecording(false)') + const catchIndex = closeAudio.indexOf('} catch', toggleIndex) + const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release') + expect(toggleIndex).toBeGreaterThanOrEqual(0) + expect(catchIndex).toBeGreaterThan(toggleIndex) + expect(catchIndex).toBeLessThan(releaseIndex) + + // stop()'s recording shutdown sits inside the try so a native throw still + // runs the finally release and error cleanup. + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0) + expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)')) }) it('routes disabled state and audio interruptions through cancel cleanup', () => { diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 2c4cf06a839..648048dc451 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -9,6 +9,7 @@ import { import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk' import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake' import { DICTATION_FINISH_TIMEOUT_MS, createMobileDictationId, @@ -60,7 +61,13 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil acceptingChunksRef.current = false pendingChunksRef.current.clear() pendingAudioBudgetRef.current.reset() - toggleRecording(false) + try { + toggleRecording(false) + } catch (err) { + // Cleanup must keep going when native recording shutdown throws, or + // the wake tag and dictation state would leak. + console.error('Failed to stop microphone recording', err) + } void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined) }, [keepAwakeOwner] @@ -186,9 +193,10 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = dictationId setStatus('processing') acceptingChunksRef.current = false - toggleRecording(false) - void keepAwakeOwner.release(dictationId).catch(() => undefined) try { + // Inside the try so a throwing native shutdown still runs the finally + // release and error cleanup. + toggleRecording(false) await Promise.allSettled(Array.from(pendingChunksRef.current)) if ( !isCurrentMobileDictationFinish( @@ -237,6 +245,9 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } catch (err) { failActiveDictation(dictationId, err) } finally { + // Hold the wake tag through chunk drain and the finish RPC: a screen + // lock mid-processing suspends the app and loses the transcript. + void keepAwakeOwner.release(dictationId).catch(() => undefined) if (finishingIdRef.current === dictationId) { finishingIdRef.current = null } @@ -257,6 +268,8 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil setError(null) }, [closeDictationAudio]) + useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef) + useEffect(() => { const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => { if (event.data === 'began' || event.data === 'blocked') {