Skip to content

fix(voice): claim the audio session before a mobile capture - #267

Open
TiaraBasori wants to merge 2 commits into
iebb:masterfrom
TiaraBasori:fix/ios-voice-recording-diagnostics
Open

TiaraBasori wants to merge 2 commits into
iebb:masterfrom
TiaraBasori:fix/ios-voice-recording-diagnostics

Conversation

@TiaraBasori

@TiaraBasori TiaraBasori commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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.

VoicePlayer re-applies AudioSessionConfiguration.music() before each track and when one is stopped, and that recipe's category is playback. playback admits no input, and flutter_sound's iOS recorder configures no hold of its own (openRecorder on 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 _startDesktopRec each dropped the exception they caught.

  • lib/chat/voice_recording_session.dart (new) owns the category for exactly one recording. playAndRecord with defaultToSpeaker, allowBluetooth and allowBluetoothA2dp, the voiceChat mode, 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.
  • restricted is separated from denied and permanentlyDenied. 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 the permissionRequired branch, had no route to Settings, and read exactly like the app-level refusal.
  • A refusal is remembered for the panel opening that met it. 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.voiceRecordingFailed records 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

  • _prepareDesktopRecorder did 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.
  • That refusal never routed to system settings. On macOS it is the only way back: record_macos answers hasPermission from AVCaptureDevice.authorizationStatus and only calls requestAccess while the status is notDetermined, so once refused the app can never prompt again. The toast was permanent and unactionable.
  • A missed desktop start called _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 (DesktopMicrophoneRefused on _ReportedCaptureFailure), routes to system settings, and a missed start keeps its stage and exception. Linux needs no microphone permission at all (record_linux answers hasPermission with true and records through parecord) and record_windows hard-wires it to true, 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 record and are untouched: _prepareCaptureSession returns 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

  • Compliant/unchanged: this change complies with applicable Telegram API Terms, preserves Telegram's basic functionality, and does not introduce behavior covered by section 1.4.
  • Section 1.4 exception: the entire feature is guarded by allowTelegramTosViolations (I_DONT_FUCKING_CARE_ABOUT_TOS), disabled in official builds, and available only in an explicitly enabled self-build.
  • Disallowed or unresolved: this PR must not be approved or merged until its classification and implementation are resolved.

Reason and affected Telegram behavior:

Local audio capture only. This configures the app's own AVAudioSession around 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)

  • I reviewed the diff for correctness, unintended behavior, secrets, and unrelated changes.
  • Formatting passed, or I recorded why it is inapplicable: dart format --output=none --set-exit-if-changed lib test → Formatted 1053 files (0 changed), exit 0.
  • Analysis passed, or I recorded why it is inapplicable: flutter analyze lib test → No issues found!.
  • Relevant tests passed, or I recorded why they are inapplicable; name the commands and test cases below. Quality CI runs flutter test --concurrency=4.
  • I checked affected build/platform paths and applicable localization/generated files; list the evidence below.
  • I completed the Terms classification above and verified that official builds cannot enable section 1.4 exceptions.

Commands, results, platform/build/UI evidence, and reasons for inapplicable checks:

/tmp/flutter-3.44.2 is the toolchain Quality CI pins (Flutter 3.44.2). Every run below used --no-pub -j 1, serially.

  • Format: /tmp/flutter-3.44.2/bin/dart format --output=none --set-exit-if-changed lib test → Formatted 1053 files (0 changed), exit 0.
  • Analysis: /tmp/flutter-3.44.2/bin/flutter analyze lib test → No issues found!.
  • Tests, serial, one file per invocation:
    • 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.
  • Mutation checks, each reverted after: removing await _prepareCaptureSession() from _startRec fails 5 cases (the claim-order, per-recording, failed-start, unclaimable-session and per-press cases); folding restricted back into the denied/permanentlyDenied branches 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']).
  • Localization/generated files: one string added (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.
  • Build/platform paths: no native file, no dependency change, no platform channel change, no pubspec change. voice_recording_session.dart uses audio_session, already a direct dependency and already used by voice_audio.dart. Verified at widget level through an injected session; the native setCategory/setActive sequence on a device is the part this harness cannot exercise (see the Change section).

Line counts (git diff --numstat against the merge base with master): 21 files changed, 803 insertions(+), 34 deletions(-).

  1   1  assets/l10n/de-DE.json
  1   1  assets/l10n/en-US.json
  1   1  assets/l10n/es-ES.json
  1   1  assets/l10n/fr-FR.json
  1   1  assets/l10n/ja-JP.json
  1   1  assets/l10n/ko-KR.json
  1   1  assets/l10n/zh-Hans-CN.json
  1   1  assets/l10n/zh-Hant-TW.json
 40   0  lib/app/diagnostic_breadcrumbs.dart
239  25  lib/chat/chat_input_bar.dart
 99   0  lib/chat/voice_recording_session.dart
  2   0  lib/l10n/app_localizations.dart
407   1  test/chat_voice_recording_test.dart
  1   0  translations/strings/de-DE/strings.xml
  1   0  translations/strings/en-US/strings.xml
  1   0  translations/strings/es-ES/strings.xml
  1   0  translations/strings/fr-FR/strings.xml
  1   0  translations/strings/ja-JP/strings.xml
  1   0  translations/strings/ko-KR/strings.xml
  1   0  translations/strings/zh-Hans-CN/strings.xml
  1   0  translations/strings/zh-Hant-TW/strings.xml

Section 1.4 exception checks

Not applicable — compliant/unchanged classification; no guarded feature is added or touched.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-22T17:02:16.779363Z
We couldn’t complete this review. Comment @codex review to try again.
76a2e53 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Automated PR review

Head: 42e0a746d9454ee8bc23ae0e698bb0c46fddd8a6 · master: 82397aef906f40e9034e062ae56eb3a3640efe6a
LLM recommendation: needs_human. This is not a GitHub approval.
Auto-merge: blocked. Only iebb can authorize the current head.

Review unavailable: Free OpenRouter review unavailable (HTTP 429); no paid fallback and no merge.

Remaining gates

  • GitHub has not confirmed a conflict-free merge.
  • Waiting for iebb to approve this exact current head.
  • Quality must attest the exact live master/head merge. Update the PR branch/commit for a fresh pull_request Quality run, then approve that head.
  • master source incomplete: lib/chat/chat_input_bar.dart
  • head source incomplete: lib/chat/chat_input_bar.dart
  • master source incomplete: lib/l10n/app_localizations.dart
  • head source incomplete: lib/l10n/app_localizations.dart
  • master source incomplete: test/chat_voice_recording_test.dart
  • head source incomplete: test/chat_voice_recording_test.dart
  • master source incomplete: translations/strings/de-DE/strings.xml
  • head source incomplete: translations/strings/de-DE/strings.xml
  • master source incomplete: translations/strings/en-US/strings.xml
  • head source incomplete: translations/strings/en-US/strings.xml
  • master source incomplete: translations/strings/es-ES/strings.xml
  • head source incomplete: translations/strings/es-ES/strings.xml
  • master source incomplete: translations/strings/fr-FR/strings.xml
  • head source incomplete: translations/strings/fr-FR/strings.xml
  • master source incomplete: translations/strings/ja-JP/strings.xml
  • head source incomplete: translations/strings/ja-JP/strings.xml
  • master source incomplete: translations/strings/ko-KR/strings.xml
  • head source incomplete: translations/strings/ko-KR/strings.xml
  • master source incomplete: translations/strings/zh-Hans-CN/strings.xml
  • head source incomplete: translations/strings/zh-Hans-CN/strings.xml
  • master source incomplete: translations/strings/zh-Hant-TW/strings.xml
  • head source incomplete: translations/strings/zh-Hant-TW/strings.xml

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.

@TiaraBasori
TiaraBasori force-pushed the fix/ios-voice-recording-diagnostics branch 4 times, most recently from c463a09 to 7efa09e Compare October 24, 2026 06:50
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.
@TiaraBasori
TiaraBasori force-pushed the fix/ios-voice-recording-diagnostics branch from 7efa09e to 42e0a74 Compare October 24, 2026 07:08

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant