mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user