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.
This commit is contained in:
Jinjing
2026-07-10 19:36:29 -07:00
parent 98aa9ba1ae
commit 643c76d027
10 changed files with 703 additions and 89 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
@@ -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<void>(() => 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 () => {
@@ -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<void>((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)
@@ -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<string | null>
): 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])
}
@@ -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<void>
@@ -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<void>(() => 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<void>(() => 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<void>(() => 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<void>(() => 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')
})
})
+162 -14
View File
@@ -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<void> = Promise.resolve()
const activeTags = new Set<string>()
const pendingCleanupTags = new Set<string>()
// Timed-out activations that may still land natively, keyed to a predicate
// saying whether their dictation still wants the tag.
const pendingActivations = new Map<string, () => boolean>()
function createOwnerId(): string {
nextOwnerId += 1
@@ -18,9 +25,82 @@ function enqueueKeepAwakeOperation(action: () => Promise<void>): Promise<void> {
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<void>): Promise<void> {
return new Promise<void>((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<void> {
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<void> {
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<void> {
}
activeTags.delete(tag)
pendingCleanupTags.delete(tag)
pendingActivations.delete(tag)
}
async function cleanupPendingTags(): Promise<void> {
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<void> {
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<void> {
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<void> {
return enqueueKeepAwakeOperation(cleanupPendingTags)
}
@@ -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')
}
@@ -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', () => {
+16 -3
View File
@@ -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') {