diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts index bfc2dbd8d29..578f1721342 100644 --- a/mobile/src/hooks/mobile-dictation-session-state.ts +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -48,18 +48,14 @@ export function isCurrentMobileDictationStart( return currentGeneration === generation && enabled && activeId === dictationId } +/** A finish is still this dictation's while nothing has superseded it: `cancel`, a disable, an + * unmount and a newer start each bump the generation or clear the active id, and most do both. */ 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 - ) + return currentGeneration === generation && enabled && activeId === dictationId } diff --git a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx index c92323a9688..c46a4b412c3 100644 --- a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx +++ b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx @@ -104,14 +104,26 @@ function audioOf(request: SentRequest): unknown { : null } +/** Answers everything the hook has sent except one method, so a case can hold that reply open. */ +function settleExcept(rpc: FakeRpcClient, sent: SentRequest[], method: string): void { + for (const request of rpc.requests.splice(0)) { + sent.push(request) + if (request.method === method) { + rpc.requests.push(request) + continue + } + request.resolve({ id: 'desktop', ok: true, result: {} }) + } +} + const held: { dictation: UseMobileDictationResult | null } = { dictation: null } -function mount(client: FakeRpcClient): void { +function mount(client: FakeRpcClient, onTranscript: (text: string) => void = () => {}): void { function Probe(): null { held.dictation = useMobileDictation({ client, enabled: true, - onTranscript: () => {}, + onTranscript, onError: () => {} }) return null @@ -191,3 +203,42 @@ describe('the audio a capture hands over as it ends', () => { ) }) }) + +describe('a finish whose dictation stopped being the current one', () => { + it('delivers no transcript when a cancel lands while the finish is in flight', async () => { + // What `finishingIdRef` was thought to guard, pinned against the two things that actually do: + // `cancel` bumps the generation and clears the active id, and the finish is read against both + // before its text reaches the composer. A transcript that arrived here would be typed into a + // field the user has already dismissed the microphone from. + const rpc = createFakeRpcClient() + const sent: SentRequest[] = [] + const transcripts: string[] = [] + mount(rpc, (text) => transcripts.push(text)) + await act(async () => { + const started = dictation().start() + await pump(rpc, sent) + await started + }) + let stopped: Promise = Promise.resolve() + await act(async () => { + stopped = dictation().stop() + // Everything but the finish, which stays in flight while the user cancels. + for (let round = 0; round < 4; round += 1) { + settleExcept(rpc, sent, 'speech.dictation.finish') + await Promise.resolve() + await Promise.resolve() + } + }) + expect(sent.map((request) => request.method)).toContain('speech.dictation.finish') + await act(async () => { + // Started rather than awaited: the cancel's own request has to be answered by the pump below + // before it settles, and awaiting it first would deadlock the case rather than the product. + const cancelled = dictation().cancel() + await pump(rpc, sent) + await cancelled + await stopped + }) + expect(transcripts).toEqual([]) + expect(dictation().status).toBe('idle') + }) +}) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 733d259e5ba..5e3e52e6297 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -46,7 +46,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget()) const acceptingChunksRef = useRef(false) const generationRef = useRef(0) - const finishingIdRef = useRef(null) useLayoutEffect(() => { // Native audio events can arrive before passive Effects flush, but refs @@ -103,8 +102,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const audioChunkQueue = { pendingChunks: pendingChunksRef.current, pendingAudioBudget: pendingAudioBudgetRef.current, - shouldReleaseBudget: (id: string) => - activeIdRef.current === id || finishingIdRef.current === id, + shouldReleaseBudget: (id: string) => activeIdRef.current === id, failActiveDictation } const sub = capture.onChunk((chunk) => { @@ -206,7 +204,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const generation = generationRef.current + 1 generationRef.current = generation - finishingIdRef.current = dictationId setStatus('processing') try { // Inside the try so a throwing native shutdown still runs the finally @@ -225,7 +222,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generation, enabledRef.current, activeIdRef.current, - finishingIdRef.current, dictationId ) ) { @@ -244,7 +240,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generation, enabledRef.current, activeIdRef.current, - finishingIdRef.current, dictationId ) ) { @@ -253,7 +248,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const transcript = rpcPayloadMember(finished, 'text') const text = typeof transcript === 'string' ? transcript.trim() : '' activeIdRef.current = null - finishingIdRef.current = null pendingChunksRef.current.clear() pendingAudioBudgetRef.current.reset() setStatus('idle') @@ -268,9 +262,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil // 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 - } } }, [capture, failActiveDictation, keepAwakeOwner]) @@ -279,7 +270,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const dictationId = activeIdRef.current generationRef.current += 1 activeIdRef.current = null - finishingIdRef.current = null closeDictationAudio(dictationId) if (client && dictationId) { await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined) @@ -308,7 +298,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const dictationId = activeIdRef.current generationRef.current += 1 activeIdRef.current = null - finishingIdRef.current = null closeDictationAudio(dictationId) capture.release() if (clientRef.current && dictationId) {