fix(study): the Speak sheet reports why a listen failed instead of closing - #232
Merged
Conversation
The Speak sheet sometimes vanished with neither a success nor an error. `onSpeechError` mapped every recognition failure to `SpeakPhase.Idle`, and the common Android ones are ordinary rather than exceptional: ERROR_NO_MATCH, ERROR_SPEECH_TIMEOUT, and the engine still busy on a fast Try again. From the reader's side a sheet that closes on its own is indistinguishable from the app having missed the tap. The reason now travels: `SpeechEvent.Error` carries its `SpeechError` into a new `SpeakPhase.Failed`, which says which failure it was and whether another go is worth offering — a refused permission and a missing language model do not change by trying again. Late errors are dropped exactly as late transcripts are, so nothing reopens a sheet the reader has moved on from. `SpeechError` gains `LanguageUnavailable` (a deck declares its language, so this is the one failure the reader can act on) and its entries become PascalCase, like every other enum that crosses to Swift here — Kotlin exports them lowercased with the separators dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…ery error Three gaps in `AndroidSpeechRecognizer`, all of which left the Speak sheet saying nothing. A listen could hang forever: the emulator's recognition service reports `onReadyForSpeech` and `onBeginningOfSpeech` and then returns neither a result nor an error, and nothing else ends the flow — the sheet sits on "Say the word" for good. One listen is now capped, with a `stopListening` grace first so a working-but-slow engine still gets to deliver. A pronunciation attempt is a word or a short phrase, so the cap cannot truncate a real one. A final result and an error can both arrive on some engines, which let "didn't catch that" overwrite a graded result; whichever lands first is now the attempt's outcome. And the API 33 codes were all falling into UNKNOWN — including ERROR_LANGUAGE_NOT_SUPPORTED, which is what a deck declaring a language the device has no model for looks like. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
The sheet gains a failure body: an icon, the reason in the reader's words, and Try again — or Close, for the two failures nothing can retry. The unavailable case moves out of a Toast and into the sheet that is already on screen, so there is one place a Speak outcome is reported rather than two. Starting a listen now waits for the previous recognizer to be destroyed before creating the next. It is destroyed in the flow's `awaitClose`, so cancelling without joining let a fast Try again create a second one first — answered with ERROR_RECOGNIZER_BUSY, which used to close the sheet. A refused microphone permission is a dismissal rather than a failed listen: nothing was started, and the rationale dialog has already said why the microphone is needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
iOS had the same silent close as Android — `onFailure` ended at `onSpeechError()`, which dismissed the sheet — and it collapsed every recognition error into `.noMatch`, so the alert covering permission and unavailability never fired for the case that actually happens. The reason now crosses to the shared ViewModel and the sheet renders it, which retires the separate alert: one place a Speak outcome is reported, the same as Android. A nil `SFSpeechRecognizer` is told apart from an unavailable one — the first says iOS has no model for the deck's language, which the reader can install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The Speak sheet sometimes closed without showing a success or an error.
StudySessionViewModel.onSpeechError()mapped every recognition failure toSpeakPhase.Idle, and on Android the common failures are ordinary rather than exceptional —ERROR_NO_MATCH,ERROR_SPEECH_TIMEOUT, and the engine still busy on a fast Try again. From the reader's side a sheet that closes on its own is indistinguishable from the app having missed the tap.Driving it on the emulator turned up two more silent paths in the same flow:
onReadyForSpeechandonBeginningOfSpeech, then returns neither a result nor an error. Nothing else ends the flow, so the sheet sits on "Say the word" for good.UNKNOWN— includingERROR_LANGUAGE_NOT_SUPPORTED, which is exactly what a deck declaring a language the device has no model for looks like. It closed the sheet like all the others.The fix
The reason travels instead of being thrown away.
SpeechEvent.Errorcarries itsSpeechErrorthrough to a newSpeakPhase.Failed, which says which failure it was and offers Try again — or a Close, for the two nothing can retry (a refused permission, a missing language model).Around that:
awaitClose, so cancelling without joining let a fast Try again create a second one first — answered withERROR_RECOGNIZER_BUSY, which closed the sheet.stopListeninggrace first so a slow-but-working engine still delivers. A pronunciation attempt is a word or a short phrase; the cap cannot truncate a real one.iOS had the same silent close, and additionally collapsed every recognition error into
.noMatch— so its alert for permission and unavailability never fired for the case that actually happens. It now reports through the same shared phase, which retires the separate alert: one place a Speak outcome is shown, on both platforms.SpeechErrorgainsLanguageUnavailable, and its entries become PascalCase like every other enum that crosses to Swift here — Kotlin exports them lowercased with the separators dropped, so aSCREAMING_SNAKEentry would cross under a name nothing in the repo can predict.Verification
./gradlew :shared:jvmTest detektAll :composeApp:assembleDebug— green. Four newStudySessionViewModelTestcases cover the reported phase, the retry target, the non-retryable failure and the late-error guard.Driven on
emulator-5554against the real homeserver ("Spanish basics", es-ES → en-US, Speak on) —journeys/09-speak-study.xmlre-run, with the outcome and three new steps recorded injourneys/RESULTS.md:ERROR_RECOGNIZER_BUSYStill manual on a device with Google speech: the Correct/Wrong outcome and which language each engine uses. The iOS half is written to match but is unverified — no macOS in this session, and
xcodebuildmcpfailed to connect.🤖 Generated with Claude Code
https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye