Repository navigation
fix(voice): claim the audio session before a mobile capture - #267
TiaraBasori wants to merge 2 commits into
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Automated PR reviewHead: Review unavailable: Free OpenRouter review unavailable (HTTP 429); no paid fallback and no merge. Remaining gates
No PR code runs with the model key or write token. Free-only routing; provider/rate-limit failures never authorize a merge. Existing review findings must be resolved, not hidden by a new recommendation. |
c463a09 to
7efa09e
Compare
The composer's mobile voice recording failed on iOS with the generic "could not record audio" toast and no usable cause. The shared AVAudioSession belongs to playback: `VoicePlayer` re-applies `AudioSessionConfiguration.music()` (`playback`) before every track and when one is stopped, and `playback` admits no input at all. flutter_sound's iOS recorder configures no hold of its own, so a recorder opened after any playback ran on a category with no microphone route and failed inside the encoder. Nothing recorded the reason: the session error, the four encoder attempts and the recorder's own failure were each discarded by a bare `catch (_)`. - `VoiceRecordingAudioSession` owns the category for exactly one recording: `playAndRecord` with the speaker and Bluetooth routes, `voiceChat` mode, and an explicit activation, claimed before the first encoder starts and handed back when the recorder stops, when no container opens, and on dispose. - `restricted` is separated from `denied` and `permanentlyDenied`. On iOS it is a system-wide microphone lock (Screen Time, MDM, the OS switch) that the app cannot prompt through; it now has its own string and routes to Settings instead of reading as "permission required" with no way forward. - A refusal is remembered for the panel opening that met it, so a second press in the same opening no longer repeats the same toast (re-requesting an already refused permission shows no sheet). Reopening the panel, returning from Settings, or coming back from another app retries. - The first encoder failure, the failed stage and the exception type reach `DiagnosticBreadcrumbs` (stage + type only, never a path or a platform message), so a device-only report is diagnosable from Feedback. Verified by 8 new widget cases plus four mutations that each break one of them: dropping the session claim, folding `restricted` back into `denied`, leaving the session claimed after a failed start, and forgetting the refusal.
Follow-up to the mobile capture fix, on the same seam. The native desktop composer met a refused microphone with one dead end: a "Microphone permission required" toast, no route to the switch that could actually change it, and — because `_prepareDesktopRecorder` did not remember its own failure — the same toast again on every press for as long as the panel stayed open. `record` only shows the macOS prompt while the grant is undetermined, so nothing else in the app could ever ask again. - A refusal is remembered for the panel opening that met it, on the same `_ReportedCaptureFailure` seam the mobile path uses, so one press reports one outcome. Opening the panel again retries. - The refusal routes to system settings, which is the only place a macOS microphone grant can be restored. Linux needs no permission at all and Windows' `hasPermission` is hard-wired to true, so this is the macOS path. - A missed desktop start keeps its stage and exception (`_showRecordingError(error:, stage: 'start')` instead of a bare call), so a desktop-only capture failure reaches `DiagnosticBreadcrumbs` like the mobile one does. Verified with 3 widget cases on already-green suites, and 3 mutations that each break one of them: dropping the settings route, not remembering the refusal (which fails the "once, not per press" and "offers a way to allow it" cases), and dropping the start stage.
7efa09e to
42e0a74
Compare
Change
Two voice-recording surfaces were both dead ends: the mobile composer (iOS) could not open a recorder at all, and the native desktop composer (macOS, Windows, Linux) met a refused microphone with a toast that could never change. Both are fixed here on one seam.
VoicePlayerre-appliesAudioSessionConfiguration.music()before each track and when one is stopped, and that recipe's category isplayback.playbackadmits no input, and flutter_sound's iOS recorder configures no hold of its own (openRecorderon iOS only answers the started callback), so a recorder opened after any playback ran on a category with no microphone route: the session could not be activated for capture and every encoder failed. The composer then reported that as a permission-shaped message while_createMobileRecorder,_startRec's four codec attempts and_startDesktopReceach dropped the exception they caught.lib/chat/voice_recording_session.dart(new) owns the category for exactly one recording.playAndRecordwithdefaultToSpeaker,allowBluetoothandallowBluetoothA2dp, thevoiceChatmode, and an explicit activation — claimed before the first encoder starts, not at panel open, so a panel the user opens and abandons never holds the device's capture route. It is released when the recorder stops (before the preview plays and before any upload), when no container opens, and on dispose; every path is idempotent.restrictedis separated fromdeniedandpermanentlyDenied. On iOS it is a system-wide microphone lock — Screen Time, MDM, or the OS microphone switch — which the app cannot prompt through at all. It gets its own string (all eight locales) and routes to Settings; previously it fell into thepermissionRequiredbranch, had no route to Settings, and read exactly like the app-level refusal.request()on an already refused permission shows no sheet, so a second press in the same opening could only repeat the same toast; it now falls through silently. Reopening the panel, returning from Settings, or coming back from another app (resumed) retries.DiagnosticBreadcrumbs.voiceRecordingFailedrecords the failed stage, the exception type and (for a failed start) the first codec that refused to open. Only those two sanitized tokens reach the breadcrumb — no path, no platform message, per the policy already stated at the top of that file — so a device-only report carries something to read from Feedback.The desktop composer had the older shape
_prepareDesktopRecorderdid not remember its own failure, so a refused microphone re-ran the permission query and re-showed "Microphone permission required" on every press for as long as the panel stayed open — three held Space presses produced four reports.record_macosanswershasPermissionfromAVCaptureDevice.authorizationStatusand only callsrequestAccesswhile the status isnotDetermined, so once refused the app can never prompt again. The toast was permanent and unactionable._showRecordingError()with no arguments: neither the breadcrumb nor the issue seam saw it, so a desktop-only capture failure left nothing behind to diagnose.Those three now share the same seam: a refusal is remembered for the panel opening that met it (
DesktopMicrophoneRefusedon_ReportedCaptureFailure), routes to system settings, and a missed start keeps its stage and exception. Linux needs no microphone permission at all (record_linuxanswershasPermissionwithtrueand records throughparecord) andrecord_windowshard-wires it totrue, so in practice the refusal path is macOS — but it is written for the platform decision the composer already makes.Affected platforms: the mobile composer (iOS and Android share the mobile recorder and the session). The native desktop composers record through
recordand are untouched:_prepareCaptureSessionreturns early for them, and there is a widget case pinning that they never claim the session.The one thing this cannot show on this box is a real iOS session: no Xcode here, so the native category/activation sequence is exercised only through the injected seam. What is verified is that the claim happens before the first encoder starts, that it is released on all three exits, and that a claim which throws is reported once and starts no encoder.
Telegram API Terms classification
allowTelegramTosViolations(I_DONT_FUCKING_CARE_ABOUT_TOS), disabled in official builds, and available only in an explicitly enabled self-build.Reason and affected Telegram behavior:
Local audio capture only. This configures the app's own
AVAudioSessionaround a voice message the user is recording, and changes which message and which route the app shows for a microphone it could not open. No request is issued differently, no message data is read, and read status, typing status, last seen, self-destructing content, and actions taken on the user's behalf are unaffected.Basic checks (every PR)
dart format --output=none --set-exit-if-changed lib test→Formatted 1053 files (0 changed), exit 0.flutter analyze lib test→No issues found!.flutter test --concurrency=4.Commands, results, platform/build/UI evidence, and reasons for inapplicable checks:
/tmp/flutter-3.44.2is the toolchain Quality CI pins (Flutter 3.44.2). Every run below used--no-pub -j 1, serially./tmp/flutter-3.44.2/bin/dart format --output=none --set-exit-if-changed lib test→Formatted 1053 files (0 changed), exit 0./tmp/flutter-3.44.2/bin/flutter analyze lib test→No issues found!.test/chat_voice_recording_test.dart→ 32/32 passed. The 11 new cases, mobile first: a recording claims the capture session before the encoder (order log['recorder.open', 'session.prepare']— the recorder opens once per panel, the category is claimed per recording); each mobile recording claims and releases the session once and the recorder is reused; a start that opens no container releases the capture session and reports one failure; a capture session that cannot be claimed reports once and starts no encoder; each press reports its own failed capture attempt; a recorder that cannot open is reported once per opening and reopening retries; a system-wide restriction shows its own string and opens Settings without opening the recorder; a denied prompt stays in the app, reports once across two presses, and re-asks after the app returns to the foreground. Plus a case pinning that the desktop composer never claims the session. Desktop: a refused desktop microphone reports once, not per press (three held Space presses → one report, one permission query, no start); a refused desktop microphone offers a way to allow it (the injected Settings opener runs exactly once); a desktop start failure names its stage (['start']).test/voice_player_start_ownership_test.dart→ 10/10 passed.test/music_playback_load_ownership_test.dart→ 5/5 passed.test/composer_input_surfaces_test.dart→ 7/7 passed.test/desktop_voice_message_controller_test.dart→ 8/8 passed.test/voice_note_preview_test.dart→ 3/3 passed.test/voice_audio_test.dart→ 2/2 passed.await _prepareCaptureSession()from_startRecfails 5 cases (the claim-order, per-recording, failed-start, unclaimable-session and per-press cases); foldingrestrictedback into thedenied/permanentlyDeniedbranches fails the restriction case; dropping the release on the no-container path fails the failed-start case; removing the remembered refusal fails the restriction and denied cases. Desktop: dropping the Settings route fails the restriction-to-Settings case; not remembering the refusal fails both the "once, not per press" case (4 reports for 3 presses) and the "offers a way to allow it" case; dropping the start stage fails the stage case (['unknown']).composerMicrophonePermissionRestricted) in all eight catalogues.python3 tool/check_l10n_strings.py→No visible UI string literals outside lib/l10n.;python3 translations/tools/check.py→8 locales x 3935 keys (11 counted): OK;python3 translations/tools/gen_assets.py --check→9 locale assets are up to date;python3 tool/gen_registration_anchors.py --check→ exit 0.pubspecchange.voice_recording_session.dartusesaudio_session, already a direct dependency and already used byvoice_audio.dart. Verified at widget level through an injected session; the nativesetCategory/setActivesequence on a device is the part this harness cannot exercise (see the Change section).Line counts (
git diff --numstatagainst the merge base with master): 21 files changed, 803 insertions(+), 34 deletions(-).Section 1.4 exception checks
Not applicable — compliant/unchanged classification; no guarded feature is added or touched.