From 2e4849527300547f1febe62dfef7ef70101bad92 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:31:56 -0700 Subject: [PATCH] Prevent mobile screen locking during voice dictation. (#7746) * Prevent mobile screen locking during voice dictation Integrate expo-keep-awake to prevent the mobile device from locking or sleeping while a voice dictation session is active. - Modularize useMobileDictation logic into separate helper files for keep-awake, audio chunking, session state, and desktop startup. - Acquire keep-awake lock only after successfully establishing a desktop session to avoid locking on stale start attempts. - Release the keep-awake lock on all completion, cancellation, error, and unmount paths. - Add source invariant unit tests to verify keep-awake ownership and strict cleanup ordering. * serialize keep-awake operations and avoid stale dictation start races - Implement a global execution queue and tag tracking for keep-awake operations to prevent concurrent races and stale deactivations. - Track failed native deactivations and retry them when a replacement hook owner mounts or starts a new dictation session. - Ensure stale or canceled desktop dictation starts do not reset the UI state or propagate outdated start/keep-awake failures. - Reuse the audio chunk queue wiring in useMobileDictation to avoid allocating new closure objects on the high-frequency microphone path. - Add comprehensive unit tests for the keep-awake and desktop start hooks. * Commit native recording during dictation session startup Commit native recording in the same continuation as the final session stale check. This prevents a queued cancellation from resurrecting the microphone recording after cleanup has already run. If microphone initialization fails or throws, acquired resources (like keep-awake locks and the remote desktop session) are properly rolled back. * 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. * Fix cancellation races in mobile dictation keep-awake handling - Run wake-lock release and dictation cancel concurrently on stale starts so a hung acquisition no longer delays the native cancel - Guard foreground reacquire retries with a run token so a stale retry chain can't deactivate a wake lock reacquired by a newer AppState transition * Update source invariant test for concurrent stale-start cleanup Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 3 + .../src/hooks/mobile-dictation-audio-chunk.ts | 52 +++ .../mobile-dictation-desktop-start.test.ts | 231 +++++++++ .../hooks/mobile-dictation-desktop-start.ts | 142 ++++++ .../mobile-dictation-foreground-keep-awake.ts | 70 +++ .../hooks/mobile-dictation-keep-awake.test.ts | 442 ++++++++++++++++++ .../src/hooks/mobile-dictation-keep-awake.ts | 236 ++++++++++ .../hooks/mobile-dictation-session-state.ts | 65 +++ .../hooks/use-mobile-dictation-source.test.ts | 253 +++++++++- mobile/src/hooks/use-mobile-dictation.ts | 229 +++++---- 11 files changed, 1594 insertions(+), 130 deletions(-) create mode 100644 mobile/src/hooks/mobile-dictation-audio-chunk.ts create mode 100644 mobile/src/hooks/mobile-dictation-desktop-start.test.ts create mode 100644 mobile/src/hooks/mobile-dictation-desktop-start.ts create mode 100644 mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts create mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.test.ts create mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.ts create mode 100644 mobile/src/hooks/mobile-dictation-session-state.ts diff --git a/mobile/package.json b/mobile/package.json index 9f91777a8..cbbc8c86b 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -35,6 +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-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 496855fc2..aafe63881 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: expo-image-picker: specifier: ^55.0.20 version: 55.0.20(expo@55.0.27) + expo-keep-awake: + specifier: ~55.0.8 + version: 55.0.8(expo@55.0.27)(react@19.2.6) expo-linking: specifier: ^55.0.15 version: 55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts new file mode 100644 index 000000000..792292e6c --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -0,0 +1,52 @@ +import { + MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, + MOBILE_DICTATION_PCM_SAMPLE_RATE +} from './mobile-dictation-pending-audio-budget' +import { bytesToBase64 } from './mobile-dictation-session-state' +import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio' +import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import type { RpcClient } from '../transport/rpc-client' + +type MobileDictationAudioChunkQueue = { + pendingChunks: Set> + pendingAudioBudget: MobileDictationPendingAudioBudget + shouldReleaseBudget: (dictationId: string) => boolean + failActiveDictation: (dictationId: string, err: unknown) => void +} + +export function enqueueMobileDictationAudioChunk( + client: RpcClient, + dictationId: string, + event: MicrophoneDataEvent, + queue: MobileDictationAudioChunkQueue +): void { + const raw = event.data + const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) + const byteLength = bytes.byteLength + if (!queue.pendingAudioBudget.tryReserve(byteLength)) { + queue.failActiveDictation( + dictationId, + new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) + ) + return + } + const sendChunk = client + .sendRequest('speech.dictation.chunk', { + dictationId, + audioBase64: bytesToBase64(bytes), + sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE + }) + .then((response) => { + if (!response.ok) { + throw new Error(response.error.message) + } + }) + .catch((err) => queue.failActiveDictation(dictationId, err)) + .finally(() => { + if (queue.shouldReleaseBudget(dictationId)) { + queue.pendingAudioBudget.release(byteLength) + } + queue.pendingChunks.delete(sendChunk) + }) + queue.pendingChunks.add(sendChunk) +} diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts new file mode 100644 index 000000000..bdd0b69d9 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -0,0 +1,231 @@ +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' + +const OK_RESPONSE = { ok: true, result: {} } as const + +type StartHarnessOptions = { + sendRequest?: (method: string) => Promise + acquire?: () => Promise + commitRecordingStart?: () => boolean +} + +function createStartHarness(options: StartHarnessOptions = {}) { + let generation = 1 + let enabled = true + let activeId: string | null = 'dictation-a' + const setIdle = vi.fn() + const release = vi.fn().mockResolvedValue(undefined) + const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true)) + const rollbackRecordingStart = vi.fn() + const sendRequest = vi.fn( + options.sendRequest ?? (async () => OK_RESPONSE) + ) as unknown as RpcClient['sendRequest'] + const client = { sendRequest } as RpcClient + const keepAwakeOwner = { + acquire: vi.fn(options.acquire ?? (async () => undefined)), + release + } as unknown as MobileDictationKeepAwakeOwner + + return { + options: { + client, + dictationId: 'dictation-a', + generation: 1, + getCurrentGeneration: () => generation, + getEnabled: () => enabled, + getActiveId: () => activeId, + clearActiveId: (dictationId: string) => { + if (activeId === dictationId) { + activeId = null + } + }, + setIdle, + keepAwakeOwner, + commitRecordingStart, + rollbackRecordingStart + }, + setNewerStart: () => { + generation = 2 + activeId = 'dictation-b' + }, + setDisabled: () => { + enabled = false + }, + getActiveId: () => activeId, + setIdle, + release, + sendRequest, + commitRecordingStart, + rollbackRecordingStart + } +} + +describe('startMobileDictationDesktopSession', () => { + it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + acquire: async () => setNewerStart() + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('sends desktop cancellation without waiting for a hung keep-awake release', async () => { + vi.useFakeTimers() + try { + let goStale = () => undefined + const harness = createStartHarness({ + acquire: () => { + goStale() + return new Promise(() => undefined) + } + }) + goStale = harness.setNewerStart + // Release queues behind the still-running acquisition, so it never settles. + harness.release.mockReturnValue(new Promise(() => undefined)) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + // Cancellation is dispatched even though release is still pending, so the + // native session tears down without waiting out its own timeout. + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + + void startPromise + } finally { + vi.useRealTimers() + } + }) + + it('returns to idle when disable makes keep-awake acquisition stale', async () => { + let setDisabled = () => undefined + const harness = createStartHarness({ + acquire: async () => setDisabled() + }) + setDisabled = harness.setDisabled + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.getActiveId()).toBeNull() + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('does not hold recording start on a hung keep-awake acquisition', async () => { + vi.useFakeTimers() + try { + const harness = createStartHarness({ + acquire: () => new Promise(() => undefined) + }) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + await expect(startPromise).resolves.toBe(true) + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.release).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + 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('Unable to activate keep awake') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + 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 () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + sendRequest: async (method) => { + if (method === 'speech.dictation.start') { + setNewerStart() + throw new Error('Desktop start failed') + } + return OK_RESPONSE + } + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('commits recording before returning a current start to the hook', async () => { + const harness = createStartHarness() + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).not.toHaveBeenCalled() + }) + + it('cleans up the keep-awake tag and desktop session when native recording throws', async () => { + const harness = createStartHarness({ + commitRecordingStart: () => { + throw new Error('Audio focus request failed') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Audio focus request failed' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) + + it('rejects and cleans up when the native recorder does not start', async () => { + const harness = createStartHarness({ commitRecordingStart: () => false }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Failed to start microphone recording' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts new file mode 100644 index 000000000..581b2b99d --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -0,0 +1,142 @@ +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' + +type StartMobileDictationDesktopSessionOptions = { + client: RpcClient + dictationId: string + generation: number + getCurrentGeneration: () => number + getEnabled: () => boolean + getActiveId: () => string | null + clearActiveId: (dictationId: string) => void + setIdle: () => void + keepAwakeOwner: MobileDictationKeepAwakeOwner + commitRecordingStart: () => boolean + rollbackRecordingStart: () => void +} + +function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean { + return isCurrentMobileDictationStart( + options.getCurrentGeneration(), + options.generation, + options.getEnabled(), + options.getActiveId(), + options.dictationId + ) +} + +function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean { + return options.getCurrentGeneration() === options.generation && options.getEnabled() +} + +function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void { + if (options.getCurrentGeneration() === options.generation) { + options.setIdle() + } +} + +// Cancel a start that went stale mid-startup. The wake-lock release and remote +// cancel are independent, so run them concurrently: awaiting release first can +// queue behind a still-running acquisition and delay cancel for the remainder +// of the native timeout. +async function cancelStaleStart( + options: StartMobileDictationDesktopSessionOptions, + { releaseKeepAwake }: { releaseKeepAwake: boolean } +): Promise { + const { client, dictationId, keepAwakeOwner } = options + options.clearActiveId(dictationId) + setIdleIfGenerationCurrent(options) + const cleanups: Promise[] = [ + client.sendRequest('speech.dictation.cancel', { dictationId }) + ] + if (releaseKeepAwake) { + cleanups.push(keepAwakeOwner.release(dictationId)) + } + await Promise.allSettled(cleanups) +} + +export async function startMobileDictationDesktopSession( + options: StartMobileDictationDesktopSessionOptions +): Promise { + const { client, dictationId, keepAwakeOwner } = options + + try { + const response = await client.sendRequest('speech.dictation.start', { dictationId }) + if (!response.ok) { + throw new Error(response.error.message) + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + options.clearActiveId(dictationId) + await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) + // Awaited cleanup may overlap a newer start; stale work must not reset or + // report over the replacement session. + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: false }) + return false + } + + // 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 cancelStaleStart(options, { releaseKeepAwake: true }) + return false + } + + try { + // Commit in the same continuation as the final stale check; returning first + // would let a queued cancel resurrect microphone recording after cleanup. + if (!options.commitRecordingStart()) { + throw new Error('Failed to start microphone recording') + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + // Native recording can partially start before throwing, so stop audio before + // releasing the wake tag and remote session. + try { + options.rollbackRecordingStart() + } catch { + // Continue releasing independently owned resources after native audio failure. + } + options.clearActiveId(dictationId) + await Promise.allSettled([ + keepAwakeOwner.release(dictationId), + client.sendRequest('speech.dictation.cancel', { dictationId }) + ]) + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + return true +} 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 000000000..d5962d9d1 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts @@ -0,0 +1,70 @@ +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 + } + // A retry from an earlier foreground event can outlive a newer reacquire and + // deactivate the recovered tag; a run token invalidated on each AppState + // change and on unmount drops superseded retry chains. + let reacquireRun = 0 + const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => { + void keepAwakeOwner.reacquire(dictationId).catch(() => { + const delay = REACQUIRE_RETRY_DELAYS_MS[attempt] + if (delay === undefined) { + return + } + setTimeout(() => { + if (reacquireRun === run && activeIdRef.current === dictationId) { + reacquireWithRetry(dictationId, attempt + 1, run) + } + }, delay) + }) + } + const sub = AppState.addEventListener('change', (state) => { + const run = ++reacquireRun + const dictationId = activeIdRef.current + if (state === 'active' && dictationId) { + reacquireWithRetry(dictationId, 0, run) + } + }) + return () => { + reacquireRun += 1 + 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 new file mode 100644 index 000000000..362f3a0cf --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const keepAwake = vi.hoisted(() => ({ + activate: vi.fn<(tag: string) => Promise>(), + deactivate: vi.fn<(tag: string) => Promise>() +})) + +vi.mock('expo-keep-awake', () => ({ + activateKeepAwakeAsync: keepAwake.activate, + deactivateKeepAwake: keepAwake.deactivate +})) + +import { + MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, + MobileDictationKeepAwakeOwner, + drainMobileDictationKeepAwakeCleanup +} from './mobile-dictation-keep-awake' + +function deferred(): { + promise: Promise + resolve: () => void + reject: (error: Error) => void +} { + let resolvePromise: (() => void) | undefined + let rejectPromise: ((error: Error) => void) | undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + resolve: () => resolvePromise?.(), + reject: (error) => rejectPromise?.(error) + } +} + +describe('MobileDictationKeepAwakeOwner', () => { + beforeEach(() => { + keepAwake.activate.mockReset().mockResolvedValue(undefined) + keepAwake.deactivate.mockReset().mockResolvedValue(undefined) + }) + + it('retries a failed native deactivation after the hook owner is replaced', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + + await firstOwner.acquire('first') + const firstTag = keepAwake.activate.mock.calls[0]?.[0] + expect(firstTag).toContain(':first') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + const replacementOwner = new MobileDictationKeepAwakeOwner() + await replacementOwner.acquire('second') + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + expect(secondTag).toContain(':second') + expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await replacementOwner.release('second') + }) + + it('serializes cancel and restart without letting a stale release deactivate the restart', async () => { + const firstActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => firstActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + + const acquireFirst = owner.acquire('first') + const releaseFirst = owner.release('first') + const acquireSecond = owner.acquire('second') + firstActivation.resolve() + await Promise.all([acquireFirst, releaseFirst, acquireSecond]) + + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + await owner.release('first') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('second') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag) + }) + + it('waits for an in-flight failed release before a replacement owner activates', async () => { + const deactivation = deferred() + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise) + + const releaseFirst = firstOwner.release('first') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(keepAwake.deactivate).toHaveBeenCalledOnce() + + const replacementOwner = new MobileDictationKeepAwakeOwner() + const acquireReplacement = replacementOwner.acquire('replacement') + expect(keepAwake.activate).toHaveBeenCalledOnce() + + deactivation.reject(new Error('Activity unavailable')) + await expect(releaseFirst).rejects.toThrow('Activity unavailable') + await acquireReplacement + + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + 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 new file mode 100644 index 000000000..f94e3fb78 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts @@ -0,0 +1,236 @@ +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 + return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}` +} + +function enqueueKeepAwakeOperation(action: () => Promise): Promise { + const operation = keepAwakeOperation.then(action) + keepAwakeOperation = operation.catch(() => undefined) + 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 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. + pendingCleanupTags.add(tag) + throw err + } + activeTags.delete(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function cleanupPendingTags(): Promise { + 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 { + private readonly ownerId = createOwnerId() + private acquiredTag: string | null = null + + acquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag && !activeTags.has(this.acquiredTag)) { + this.acquiredTag = null + } + if (this.acquiredTag === tag) { + return + } + if (this.acquiredTag) { + const previousTag = this.acquiredTag + 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) + } + // 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 () => { + 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 + } 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() + } + }) + } + + private createTag(dictationId: string): string { + return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}` + } +} + +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 new file mode 100644 index 000000000..bfc2dbd8d --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -0,0 +1,65 @@ +// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because +// Metro cannot resolve Node builtins in a React Native bundle. +import { Buffer } from 'buffer' + +import type { RpcClient } from '../transport/rpc-client' + +export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' + +export type UseMobileDictationOptions = { + client: RpcClient | null + enabled: boolean + onTranscript: (text: string) => void + onError?: (error: Error) => void +} + +export type UseMobileDictationResult = { + status: DictationStatus + isStarting: boolean + isRecording: boolean + isProcessing: boolean + error: string | null + start: () => Promise + stop: () => Promise + cancel: () => Promise +} + +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') +} + +export function createMobileDictationId(): string { + return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function isCurrentMobileDictationStart( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + dictationId: string +): boolean { + return currentGeneration === generation && enabled && activeId === dictationId +} + +export function isCurrentMobileDictationFinish( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + finishingId: string | null, + dictationId: string +): boolean { + return ( + currentGeneration === generation && + enabled && + activeId === dictationId && + finishingId === dictationId + ) +} diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts index 6319e2d9b..0649e7ee5 100644 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') +const audioChunkSource = readFileSync( + new URL('./mobile-dictation-audio-chunk.ts', import.meta.url), + 'utf8' +) +const keepAwakeSource = readFileSync( + new URL('./mobile-dictation-keep-awake.ts', import.meta.url), + 'utf8' +) +const desktopStartSource = readFileSync( + new URL('./mobile-dictation-desktop-start.ts', import.meta.url), + 'utf8' +) +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) + expect(start).toBeGreaterThanOrEqual(0) + const end = sourceText.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return sourceText.slice(start, end) +} function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) + return sliceSource(source, startPattern, endPattern) +} + +function sliceDesktopStartBetween(startPattern: string, endPattern: string): string { + return sliceSource(desktopStartSource, startPattern, endPattern) } describe('useMobileDictation source invariants', () => { @@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => { }) it('reserves pending audio bytes before encoding microphone chunks', () => { - const microphoneEffect = sliceBetween( - "addExpoTwoWayAudioEventListener('onMicrophoneData'", - 'return () => sub.remove()' + const microphoneEffect = sliceSource( + audioChunkSource, + 'export function enqueueMobileDictationAudioChunk', + ' queue.pendingChunks.add(sendChunk)' ) const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)') @@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => { expect(encodeIndex).toBeGreaterThanOrEqual(0) expect(reserveIndex).toBeLessThan(encodeIndex) expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') - expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)') + expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)') + expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event') + }) + + it('reuses audio chunk queue wiring across microphone events', () => { + const queueIndex = source.indexOf('const audioChunkQueue =') + const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'") + + expect(queueIndex).toBeGreaterThanOrEqual(0) + expect(queueIndex).toBeLessThan(listenerIndex) + expect(source).toContain( + 'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)' + ) }) it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { @@ -48,4 +89,198 @@ describe('useMobileDictation source invariants', () => { expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) }) + + it('keeps mobile dictation keep-awake ownership beside the hook', () => { + expect(source).toMatch( + /import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/ + ) + expect(source).toContain( + 'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])' + ) + expect(keepAwakeSource).toContain('activateKeepAwakeAsync') + expect(keepAwakeSource).toContain('deactivateKeepAwake') + expect(keepAwakeSource).not.toMatch(/\bactivateKeepAwake\s*\(/) + }) + + it('acquires keep-awake only after desktop start and stale-start guards', () => { + const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =') + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const desktopStartIndex = startBody.indexOf( + "client.sendRequest('speech.dictation.start', { dictationId })" + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession') + const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)') + + expect(desktopStartIndex).toBeGreaterThanOrEqual(0) + expect(acquireIndex).toBeGreaterThan(desktopStartIndex) + expect(desktopSessionIndex).toBeGreaterThanOrEqual(0) + expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex) + expect(hookStartBody).toContain('commitRecordingStart: () => {') + expect(startBody).toContain('options.commitRecordingStart()') + + const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex) + expect(beforeAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(sessionStateSource).toContain( + 'currentGeneration === generation && enabled && activeId === dictationId' + ) + }) + + it('re-checks stale-start guards after awaited keep-awake acquisition', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const returnStartedIndex = startBody.indexOf('return true') + const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex) + + expect(afterAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })') + + // The stale-start cleanup must release the wake tag and cancel the desktop + // session (concurrently, so a hung acquisition can't delay the cancel). + const cancelStaleStartBody = sliceDesktopStartBetween( + 'async function cancelStaleStart', + 'export async function startMobileDictationDesktopSession' + ) + expect(cancelStaleStartBody).toContain( + "client.sendRequest('speech.dictation.cancel', { dictationId })" + ) + expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))') + expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)') + }) + + 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('.acquire(dictationId)') + expect(acquireIndex).toBeGreaterThanOrEqual(0) + const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex) + expect(staleCheckIndex).toBeGreaterThan(acquireIndex) + + // 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', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan( + closeAudio.indexOf('void keepAwakeOwner.release') + ) + expect(closeAudio).toContain('.catch(() => undefined)') + + const cleanupSlices = [ + sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), + sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'), + sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') + ] + + for (const cleanupSlice of cleanupSlices) { + expect(cleanupSlice).toContain('closeDictationAudio(dictationId)') + } + + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan( + stopBody.indexOf('await Promise.allSettled') + ) + // 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', () => { + const interruptionEffect = sliceBetween( + "addExpoTwoWayAudioEventListener('onAudioInterruption'", + 'return () => sub.remove()' + ) + const disabledEffect = sliceBetween( + 'useEffect(() => {\n if (!enabled) {', + ' }, [cancel, enabled])' + ) + + expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'") + expect(interruptionEffect).toContain('void cancel()') + expect(disabledEffect).toContain('void cancel()') + }) + + it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => { + expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()') + expect(keepAwakeSource).toContain( + '`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`' + ) + expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise = Promise.resolve()') + expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set()') + expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3) + expect(keepAwakeSource).toContain( + 'const targetTag = dictationId ? this.createTag(dictationId) : null' + ) + expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))') + expect(keepAwakeSource).toContain('await cleanupPendingTags()') + }) }) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index a1028cf92..648048dc4 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,7 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { addExpoTwoWayAudioEventListener, initialize, @@ -9,45 +6,27 @@ import { tearDown, toggleRecording } from '@orca/expo-two-way-audio' -import type { RpcClient } from '../transport/rpc-client' +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 { - MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, - MOBILE_DICTATION_PCM_SAMPLE_RATE, - MobileDictationPendingAudioBudget -} from './mobile-dictation-pending-audio-budget' + DICTATION_FINISH_TIMEOUT_MS, + createMobileDictationId, + isCurrentMobileDictationFinish +} from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { + DictationStatus, + UseMobileDictationOptions, + UseMobileDictationResult +} from './mobile-dictation-session-state' -type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' - -type UseMobileDictationOptions = { - client: RpcClient | null - enabled: boolean - onTranscript: (text: string) => void - onError?: (error: Error) => void -} - -export type UseMobileDictationResult = { - status: DictationStatus - isStarting: boolean - isRecording: boolean - isProcessing: boolean - error: string | null - start: () => Promise - stop: () => Promise - cancel: () => Promise -} - -const DICTATION_FINISH_TIMEOUT_MS = 75_000 - -function bytesToBase64(bytes: Uint8Array): string { - return Buffer.from(bytes).toString('base64') -} - -function createDictationId(): string { - return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` -} +export type { UseMobileDictationResult } from './mobile-dictation-session-state' export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult { const { client, enabled, onTranscript, onError } = options + const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, []) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const activeIdRef = useRef(null) @@ -77,6 +56,23 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil onErrorRef.current?.(normalized) }, []) + const closeDictationAudio = useCallback( + (dictationId?: string | null) => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + 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] + ) + const failActiveDictation = useCallback( (dictationId: string, err: unknown) => { const client = clientRef.current @@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil return } activeIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } reportError(err) }, - [reportError] + [closeDictationAudio, reportError] ) useEffect(() => { + // Microphone events are a hot path; reuse this wiring instead of allocating + // a queue object and release predicate for every audio chunk. + const audioChunkQueue = { + pendingChunks: pendingChunksRef.current, + pendingAudioBudget: pendingAudioBudgetRef.current, + shouldReleaseBudget: (id: string) => + activeIdRef.current === id || finishingIdRef.current === id, + failActiveDictation + } const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => { const client = clientRef.current const dictationId = activeIdRef.current if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) { return } - const raw = event.data - const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) - const byteLength = bytes.byteLength - if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) { - failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)) - return - } - const sendChunk = client - .sendRequest('speech.dictation.chunk', { - dictationId, - audioBase64: bytesToBase64(bytes), - sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE - }) - .then((response) => { - if (!response.ok) { - throw new Error(response.error.message) - } - }) - .catch((err) => failActiveDictation(dictationId, err)) - .finally(() => { - if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) { - pendingAudioBudgetRef.current.release(byteLength) - } - pendingChunksRef.current.delete(sendChunk) - }) - pendingChunksRef.current.add(sendChunk) + enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue) }) return () => sub.remove() }, [failActiveDictation, reportError]) @@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error('Failed to initialize microphone') } - const dictationId = createDictationId() + const dictationId = createMobileDictationId() activeIdRef.current = dictationId - try { - const response = await client.sendRequest('speech.dictation.start', { dictationId }) - if (!response.ok) { - throw new Error(response.error.message) - } - } catch (err) { - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - setStatus('idle') - throw err - } - if ( - generationRef.current !== generation || - !enabledRef.current || - activeIdRef.current !== dictationId - ) { - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - setStatus('idle') - return - } - acceptingChunksRef.current = true - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(true) - setStatus('recording') - }, []) + await startMobileDictationDesktopSession({ + client, + dictationId, + generation, + getCurrentGeneration: () => generationRef.current, + getEnabled: () => enabledRef.current, + getActiveId: () => activeIdRef.current, + clearActiveId: (id) => { + if (activeIdRef.current === id) { + activeIdRef.current = null + } + }, + setIdle: () => setStatus('idle'), + keepAwakeOwner, + commitRecordingStart: () => { + acceptingChunksRef.current = true + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + if (!toggleRecording(true)) { + return false + } + setStatus('recording') + return true + }, + rollbackRecordingStart: () => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + toggleRecording(false) + } + }) + }, [keepAwakeOwner]) const stop = useCallback(async () => { const client = clientRef.current @@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = dictationId setStatus('processing') acceptingChunksRef.current = false - toggleRecording(false) 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 ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error(response.error.message) } if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -257,11 +245,14 @@ 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 } } - }, [failActiveDictation]) + }, [failActiveDictation, keepAwakeOwner]) const cancel = useCallback(async () => { const client = clientRef.current @@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } setStatus('idle') setError(null) - }, []) + }, [closeDictationAudio]) + + useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef) useEffect(() => { const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => { @@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) void tearDown() if (clientRef.current && dictationId) { void clientRef.current @@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil .catch(() => undefined) } } - }, []) + }, [closeDictationAudio]) return { status,