Commit Graph
88 Commits
Author SHA1 Message Date
Edison Jwa fe6e07353e chore: restore product scaffold to rollback baseline 2026-05-29 14:02:04 +09:00
Edison Jwa 487aff4e7c fix: improve voice and chat controls 2026-05-25 18:26:56 +09:00
Edison Jwa d03ea937e6 fix: refine linux voice runtime behavior 2026-05-25 18:26:49 +09:00
Edison Jwa a2d686d9d0 feat: promote linux native audio path 2026-05-25 17:42:06 +09:00
Edison Jwa 862f83b297 fix: parse linux distro metadata 2026-05-25 16:02:01 +09:00
Edison Jwa 4a2a05ef83 fix: keep diagnostics available while connecting 2026-05-25 16:01:48 +09:00
Edison Jwa b8df25a195 Reduce Linux setup ambiguity and surface desktop input/message failures honestly
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful.

Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies
Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped
Confidence: medium
Scope-risk: moderate
Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance
Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib
Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
2026-05-25 11:55:10 +09:00
Edison Jwa 1709ae2ac2 Route Android system back through the Flutter shell
Constraint: SRS-163 requires Android back to be handled by the shell/platform layer, and the Dart service existed but was not wired into the live app.
Rejected: Leave BackIntentService test-only and unwired | System back would bypass app policy on Android.
Confidence: medium
Scope-risk: narrow
Directive: Keep back-intent probe state in sync with every new dialog or pushed route added to the Flutter shell.
Tested: dart format apps/chanora_flutter/lib/main.dart; flutter analyze lib/main.dart test/services/back_intent_service_test.dart; flutter test test/services/back_intent_service_test.dart
Not-tested: Manual Android device back-navigation smoke test
2026-05-25 03:21:02 +09:00
Edison Jwa 5515ff6643 feat: stabilize voice activity and audio routing 2026-05-25 01:19:09 +09:00
Edison Jwa d7556cd39f feat: request Android startup permissions 2026-05-25 01:10:10 +09:00
Edison Jwa b8195acc9b perf: avoid duplicate bootstrap work on join 2026-05-24 21:02:16 +09:00
Edison Jwa b494f8902d perf: short-circuit granted mic permission checks 2026-05-24 20:54:49 +09:00
Edison Jwa 7d5d8c2c90 feat: integrate chat voice and diagnostics client 2026-05-23 06:51:55 +09:00
Edison Jwa 7e28791ec2 Handle pokes outside chat tabs 2026-05-23 05:25:36 +09:00
Edison Jwa bf284018e6 feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
2026-05-22 09:29:57 +09:00
Edison Jwa 6af4ecab0f feat(voice): add iOS VAD runtime support 2026-05-21 20:51:45 +09:00
Edison Jwa 171baf6e41 fix(voice): clamp transmit when speakers muted 2026-05-20 16:37:48 +09:00
Edison Jwa f8a9c7a422 fix(android): add output device picker 2026-05-20 16:26:13 +09:00
Edison Jwa 256874997b fix(android): request microphone permission on startup 2026-05-20 14:52:34 +09:00
Edison Jwa bc1bd89424 fix(android): strengthen user voice indicators 2026-05-20 14:52:34 +09:00
Edison Jwa b21fc16ee6 fix(android): clarify channel and voice status icons 2026-05-20 14:52:34 +09:00
Edison Jwa 34121b2eb7 fix(android): use row-only channel joins 2026-05-20 14:52:34 +09:00
Edison Jwa 48a38dba46 fix(android): prompt from locked channel rows 2026-05-20 14:52:34 +09:00
Edison Jwa f8b6485390 fix(android): show password action only for locked channels 2026-05-20 14:52:34 +09:00
Edison Jwa 273eaf21c1 fix(android): add channel password join action 2026-05-20 14:52:34 +09:00
Edison Jwa 42d0ca0953 fix(android): polish app chrome and join guard 2026-05-20 14:52:34 +09:00
Edison Jwa bc70da51df fix(android): derive current channel from snapshot 2026-05-20 14:52:34 +09:00
Edison Jwa 3ae8e1ab77 fix(android): adapt fold layout and diagnostics 2026-05-20 14:52:34 +09:00
Edison Jwa 30ff955439 fix(android): clear permission-owned hard mute 2026-05-20 14:52:34 +09:00
Edison Jwa eb1f4b895e fix(android): remove cpal product wording 2026-05-20 14:52:34 +09:00
Edison Jwa 210a6a0e11 fix(android): unblock mic permission startup 2026-05-20 14:52:34 +09:00
Edison Jwa 8c253f1d4d feat(voice): harden Android audio and channel joins 2026-05-19 01:58:07 +09:00
EdisonJwa c4145a8727 feat(flutter,android): permission state banner + AndroidPermissionsService + voice-join gate
Dart consumer for the Android permission state pipeline. New
AndroidPermissionsService listens on the app.chanora/android_permissions
MethodChannel and exposes a ValueListenable for the UI. The voice-join
flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and
clamps to listen-only via setHardMute on denial. A non-modal banner
above the VoiceBar surfaces the Grant / Open Settings action depending
on whether the state is Denied or PermanentlyDenied. On non-Android
hosts the service short-circuits to granted; the banner is never built.

Also adds the BackIntentService Dart consumer (back_intent_policy +
back_intent_service) which the Kotlin BackIntentBridge invokes via
MethodChannel for deterministic route-pop ordering.

Trace: SDD-028, SDD-106, SRS-163, SRS-209.
2026-05-18 12:48:28 +08:00
EdisonJwa 7966a7c8c6 feat(bridge,android): BridgeEvent::PermissionState + JNI publish hook + c++_shared link
Per SDD-106 §5 add BridgeEvent::PermissionState{permission, state}
with the PermissionStateKind enum (Granted, Denied, PermanentlyDenied,
Unknown). The Kotlin side publishes mid-session permission changes
through a new JNI entry point Java_app_chanora_chanora_1flutter
_MainActivity_publishPermissionState routed by the new
permission_jni.rs module; the Rust audio engine subscribes and
authoritatively clamps the transmit gate (see SDD-106 §6).

Adds crates/chanora_bridge/build.rs to emit
cargo:rustc-link-lib=dylib=c++_shared on Android so libchanora_bridge
.so carries DT_NEEDED libc++_shared.so; this is required by Android
API 24+ per-library linker namespaces to resolve __cxa_pure_virtual
and friends at System.loadLibrary time.

Includes the FRB-regenerated Dart counterparts so each commit is
independently buildable.

Trace: SDD-105, SDD-106 §5, SDD-118 item 6 (extended).
2026-05-18 12:32:01 +08:00
Edison Jwa dc9c5c0a4e feat: multi-platform bug fixes, Android audio path, and build tooling
Flutter UI fixes:
- Fix stale channel badge/speaker when moved by others (derive current
  channel from ownClientId instead of optimistic local state)
- Fix Linux PTT via focused fallback key handler
- Distinguish ServerQuery clients with terminal icon in client list
- Reduce duplicate current-channel badge display
- Prevent PTT key-bind save from permanently closing voice settings
- Fix Linux GTK reopen-after-close (quit app on window destroy)
- Fix focused PTT: consume key events, release held keys on
  disconnect/leave-channel/mode/backend changes, suppress stale errors

Flutter Rust bridge:
- Thread is_server_query flag through protocol→bridge→Dart
- Add own_client_id to BridgeSnapshot DTO
- Add log_file_path_str() for platform log path queries

Rust protocol:
- Add ServerQuery test coverage (query_client_type_maps_to_server_query_flag)
- Split reqwest TLS: native-tls for desktop/iOS, rustls for Android

Rust audio:
- Upgrade cpal 0.16→0.17.3 with API adjustments (SampleRate, description())
- Suppress Android-only dead-code warnings (open_log_file, keyring_account)

Android build tooling:
- tools/build-opus-android.sh: NDK auto-discovery, correct CMake
  Android variables (ANDROID_ABI, ANDROID_PLATFORM), portable baseline
- tools/build-android-rust.sh: build+copy Rust cdylib for arm64-v8a,
  armeabi-v7a, x86_64 into android/app/src/main/jniLibs/
- Add jniLibs/ to .gitignore

Rust bridge:
- Guard open_log_file() on non-Android (Android uses logcat)
2026-05-18 00:13:21 +09:00
Edison Jwa 72c6e14797 feat: modern macOS window chrome + Linux build script
macOS:
- Transparent title bar with hidden title, full-size content view
- macOS: inline Row header (no AppBar) with 56px traffic-light pad
- Other platforms: standard Material AppBar unchanged
- App name 'Chanora' in CFBundleName/CFBundleDisplayName (iOS + macOS)
- NSLocalNetworkUsageDescription added to both platforms

Linux:
- tools/build-linux.sh: builds Rust .so + Flutter bundle + tarball
- Verifies GTK3, libopus dev headers, Rust target
- Copies libchanora_bridge.so into bundle/lib/
2026-05-17 22:29:42 +09:00
Edison Jwa 4ecea09bbc feat: add permission-denied dialog for macOS network/mic access
When macOS denies network access (PermissionDenied), show a localized
dialog explaining how to grant permission in System Settings, with
an 'Open System Settings' button that opens directly to the Local
Network privacy pane.

Also adds author info (Edison Jwa) to About dialog and moves
diagnostics button from AppBar into About dialog.
2026-05-17 21:57:56 +09:00
Edison Jwa 7a59f5b9a1 feat(ios,p0): iOS P0 platform, audio fixes, channel UX 2026-05-17 22:00:00 +09:00
EdisonJwa ecf9370db1 fix(ui): preserve '-rc.8' in About dialog despite iOS version sanitisation (rc.8+65)
Two related fixes for the version-display work landed at 97a6ba6:

PROBLEM 1: build counter stuck at +59 on the device.
The user reported the About dialog showed v1.0.0-rc.8+59 even
though pubspec.yaml has been bumping (60 -> 61 -> 62 -> 63 -> 64).
Root cause: Xcode caches ios/Flutter/Generated.xcconfig (which
holds FLUTTER_BUILD_NUMBER) and doesn't regenerate it on Cmd+R
unless inputs change. 'flutter pub get' also doesn't rewrite it.
Only 'flutter build ios' or deleting the file forces regen.

Fix (operational, not code-side): the Generated.xcconfig file on
the Mac was deleted + regenerated and is now at
FLUTTER_BUILD_NUMBER=64, so the next Xcode Run picks up the
correct value. Going forward, if the build counter ever lags
again the workaround is:

    rm ios/Flutter/Generated.xcconfig && flutter pub get

before running from Xcode. We may add this to a build-doc note
or a Makefile target during the rc.8 wrap-up.

PROBLEM 2: 'v1.0.0-rc.8' was being displayed as 'v1.0.0.8'.
CFBundleShortVersionString on iOS rejects non-numeric
characters, so Flutter strips the '-rc.8' suffix to '.8' when
populating Info.plist. package_info_plus.version reflects that
mangled value. CFBundleVersion (the build counter) is passed
through intact, so the issue affects only the semver half.

Fix: split _kAppVersion resolution. Hardcode the semver baseline
as _kSemverBaseline = 'v1.0.0-rc.8' (kept in sync with the git
tag + pubspec semver portion; bumped once per release-candidate
cycle, not per test build). Use package_info_plus for the
'+<buildNumber>' suffix only, where CFBundleVersion survives the
sanitiser unchanged. Final display becomes
'v1.0.0-rc.8+<n>' (e.g. 'v1.0.0-rc.8+65').

flutter analyze: clean.

Build counter 64 -> 65. After Xcode Clean Build Folder + Run the
About dialog should now read 'v1.0.0-rc.8+65' on the iPhone.
2026-05-17 01:18:06 +08:00
EdisonJwa 97a6ba6b55 feat(ui): show build number in About dialog (v1.0.0-rc.8+<build>)
Add package_info_plus 8.3.1 (Flutter Community Plus, BSD-3, ~3M
downloads) and resolve the displayed version string from the
platform manifest at app init. The string shown in the About
dialog is now formatted as 'v<version>+<build>' (e.g.
'v1.0.0-rc.8+60') where both halves come from the SAME
pubspec.yaml 'version:' field that Flutter uses to populate iOS's
CFBundleShortVersionString + CFBundleVersion and Android's
versionName + versionCode.

Motivation: during the iOS VoiceProcessingIO audio rollout the
user is rebuilding repeatedly from Xcode. Without a build-number
suffix there is no way to confirm from inside the app which
commit produced the build under test — they all read
'v1.0.0-rc.8'. Every test commit going forward bumps the +<build>
field in pubspec.yaml so the About dialog uniquely identifies the
build.

Implementation:
* pubspec.yaml: version 1.0.0-rc.8+59 -> +60 (this commit is a
  test build). Add package_info_plus: ^8.0.0.
* main.dart: _kAppVersion changes from 'const String' to
  'String' (resolved at runtime). Populated in main() before
  runApp() via new _resolveAppVersion() helper that calls
  PackageInfo.fromPlatform() and formats 'v<info.version>+<info.buildNumber>'.
  Falls back to the hardcoded 'v1.0.0-rc.8' baseline if the
  platform call fails (extremely unlikely; the platform channel
  is a constant lookup).

The consumer site at the About dialog (l10n.aboutVersion(_kAppVersion))
is unchanged — the variable still resolves to a String. flutter
analyze: clean.
2026-05-17 00:49:05 +08:00
EdisonJwa 2f6cdd3ad4 Revert "diag(ios,audio): log AVAudioSession state + route changes around picker overrides"
This reverts commit da631a2bef.
2026-05-17 00:17:10 +08:00
EdisonJwa da631a2bef diag(ios,audio): log AVAudioSession state + route changes around picker overrides
Add structured NSLog instrumentation to AppDelegate.swift and debugPrint
chains in voice_compact.dart::_AudioOutputPickerSheetState so we can
correlate user picker taps with what iOS actually does to the route.

Three diagnostic streams:

* 'chanora.session[<tag>]' from Swift — full session snapshot (category,
  mode, sampleRate, ioBufferDuration, current route inputs+outputs,
  preferredInput) emitted on every setActive and every
  AVAudioSession.routeChangeNotification with the reason decoded
  (override / routeConfigurationChange / newDeviceAvailable / etc).
* 'chanora.route[<tag>]' from Dart — current route's inputs+outputs
  emitted before/after every overrideOutputAudioPort or
  setPreferredInput call, plus a delayed re-check at +250 ms to detect
  silent reverts.
* Existing 'chanora: ...' debugPrint lines from the picker now include
  the OK case (override returned, setPreferredInput returned) so we see
  a positive signal in the log when the API didn't throw.

Used to root-cause the 'speaker selector not working' issue: the
hypothesis is that cpal's RemoteIO AudioUnit reacts to its own format
configuration notifications by triggering routeConfigurationChange
that reverts our Dart-side override. The logs will confirm or deny
this — if we see 'chanora.session[routeChange.override] out=Speaker'
followed by 'chanora.session[routeChange.routeConfigurationChange]
out=Receiver' within a few hundred ms, that's the smoking gun.

Pure diagnostic commit. No behavioural change. Logs are NSLog +
debugPrint so they appear in Xcode console / 'flutter logs' / the
device log via Console.app or 'devicectl device log'.
2026-05-16 23:57:50 +08:00
EdisonJwa b1d117033e fix(ios,audio): don't call setPreferredInput after speaker/receiver override (silent route reversion)
User report: 'speakerphone (built-in mic input)' logs printed
successfully but audio output didn't actually switch to speaker.
No exception thrown by either AVAudioSession call.

Root cause:

Previous _selectSpeaker called:
  1. await overrideOutputAudioPort(.speaker)
  2. await setPreferredInput(builtInMic)

Apple-documented behavior in .voiceChat mode: when
setPreferredInput is called, iOS recalculates the entire route
based on the natural input/output pairing for the selected port.

Built-in mic's natural output pairing is the receiver/earpiece
(matches the 'I'm talking on a phone' UX of .voiceChat). So step 2
caused iOS to SILENTLY REVERT the speaker override from step 1
and route output back through the earpiece.

Net: await chain returned without exception (both calls 'succeeded'
in API terms), debugPrint logged the success message, but the user
heard the call audio coming out of the earpiece, not the speaker.

Same issue affected _selectReceiver (after the speaker bug fix
was reverted): redundant setPreferredInput(builtInMic) call risked
the same recalc race.

Fix:

  * _selectSpeaker: only call overrideOutputAudioPort(.speaker).
    No setPreferredInput. The override alone is sufficient \u2014 the
    input stays on whatever the system was already using (built-in
    mic by default, or BT/wired if connected).

  * _selectReceiver: only call overrideOutputAudioPort(.none).
    No setPreferredInput. Removing the speaker override naturally
    returns to .voiceChat's default route (receiver).

  * _selectInput (BT / wired / USB): unchanged. These inputs PAIR
    their own output device, so .none + setPreferredInput is the
    correct combo (user hears audio through the same device they
    speak into).

flutter build ios --release --no-codesign: 21.2 s, Runner.app
30.4 MB.
2026-05-16 21:36:59 +08:00
EdisonJwa 9413703372 fix(ios,audio): drop .defaultToSpeaker; speakerphone toggle now actually switches
User report: 'speaker change not work' \u2014 selecting Speaker or
iPhone receiver in the audio output picker had no audible effect.

Root cause:

AVAudioSession was configured with category options
[.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] +
mode .voiceChat. The .defaultToSpeaker flag tells iOS 'this app's
baseline output route is the speakerphone, even though .voiceChat
mode would normally route to the receiver.'

When the user picked Speaker:
  overrideOutputAudioPort(.speaker)   <- already at speaker baseline; no-op
When the user picked iPhone receiver:
  overrideOutputAudioPort(.none)       <- removes speaker OVERRIDE,
                                          restores baseline = .defaultToSpeaker
                                          = speakerphone. Receiver
                                          row silently mapped to speaker.

So both rows produced the same audible state. The picker UI changed
the selected radio but the route didn't actually move.

Fix:

  1. AppDelegate.swift: drop .defaultToSpeaker from options. With
     pure .voiceChat mode (no .defaultToSpeaker), the baseline is
     the receiver/earpiece. overrideOutputAudioPort then works as
     documented:
       Default                      = receiver
       overrideOutputAudioPort(.speaker)  -> speakerphone
       overrideOutputAudioPort(.none)     -> back to receiver
       BT/AirPods connected         -> automatic
       Wired headphones plugged in  -> automatic

  2. voice_compact.dart: replace 'catch (_) {/* ignore */}' silent
     swallow with debugPrint logging of (a) the actual exception
     and (b) which route was selected. So if iOS rejects an
     override (e.g. wired headphones plugged in), we can see WHY
     in the device log instead of a silent picker no-op.

  3. _selectSpeaker also now sets preferredInput to the built-in
     mic so input + output stay consistent. Previously the
     speakerphone override could leave the mic still routed to a
     previously-selected BT input \u2014 user hears self through
     speaker but server hears nothing.

flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
2026-05-16 21:32:41 +08:00
EdisonJwa be160f5edd fix(ui,core): auto-VoiceState on connect + StatefulWidget dialogs (no PTT after join, save-bookmark crash)
Two user-reported issues addressed:

1. 'when user join the server aka default channel, but there are
   no ptt button also can not talk'

   Root cause: TS3 servers auto-place a newly-connected client into
   the server's default channel. We did not detect that. The UI
   gated all voice controls (PTT button, mic/headset AppBar icons,
   voice modal entry point) on _inChannel which was only flipped
   true by an explicit voice_join() call from the user. So after
   connect the user saw themselves in the default channel via the
   channel tree but had no way to talk.

   Fix: in chanora_core::Session::connect(), after the initial
   snapshot resolves, call find_own_in(&snap) to determine whether
   the server placed us in a channel. If yes, voice_selector
   .set_in_channel(true) + emit SessionEvent::VoiceState
   { in_channel: true } + ensure_audio_running. This treats the
   server-side default channel placement identically to a user-
   driven voice_join: the UI receives a VoiceState(true) event and
   renders all voice controls.

   Tolerates audio engine startup failure the same way voice_join
   does \u2014 server-side we are in the channel regardless; if mic
   permission / device init fails, the UI gains the controls and
   emits SessionEvent::AudioStopped so the user can resolve the
   underlying issue.

   Drops the inner lock before calling the public helpers because
   set_in_channel + emit_voice_state + ensure_audio_running all
   re-lock self.inner.

2. 'save bookmark cause a crash framework.dart line 6268
   _dependents.isEmpty is not true'

   Root cause: the showDialog-with-inline-TextEditingController-
   dispose anti-pattern. _onAddCurrentBookmark and
   _askChannelPassword both constructed a TextEditingController in
   the surrounding async function, passed it to a dialog's
   TextField via the dialog builder, then called ctl.dispose()
   synchronously after  returned.

   On iOS the dialog route pop animation is still mid-flight when
   showDialog's Future resolves. The inline dispose tore the
   controller out from under EditableText while EditableText still
   held InheritedWidget dependencies on the dialog route (theme,
   localizations, default text style). When the dialog route's
   InheritedElement then deactivated as part of the pop animation,
   the framework's debug-only assertion _dependents.isEmpty tripped
   because the disposed dialog tree had not finished detaching its
   dependents yet.

   Fix: hoist both dialogs into dedicated StatefulWidgets
   (_BookmarkNameDialog and _ChannelPasswordDialog) that own their
   own TextEditingController. The State.dispose() runs as part of
   the dialog's normal unmount lifecycle, AFTER the pop animation
   completes and all InheritedWidget dependencies have been cleared.
   No race possible.

   Bonus: dialog builders now use the dialog's own ctx for
   AppL10n.of(...), Theme.of(...), and Navigator.of(...) calls
   uniformly, rather than capturing the outer _HomePageState
   context's l10n in a closure. That avoids a secondary leak where
   the dialog widget tree held references back to the outer
   route's InheritedElements through closure capture.

   Added onSubmitted: -> pop(_ctl.text) on both fields so iOS
   hardware-keyboard 'return' submits the dialog (small UX win
   discovered while restructuring).

iOS build: flutter build ios --release --no-codesign 20.3 s,
Runner.app 30.4 MB. flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged). cargo test -p chanora_core --release
--lib: 13 passed.
2026-05-16 21:09:54 +08:00
EdisonJwa f0016155aa feat(ui,ios): replace audio_router with audio_session + custom VoIP-style picker
User reported: the 'output device' picker only listed AirPlay
destinations (other iPhones / AirPlay speakers / AppleTV) and not
the speaker / iPhone receiver / AirPods / wired headset choices.

Root cause: audio_router 1.1.1's iOS path uses AVRoutePickerView,
which is Apple's **AirPlay** picker UI \u2014 by design it only lists
AirPlay-eligible output destinations, NOT the input/output route
choices we need (speaker vs receiver vs Bluetooth HFP vs wired).
AVRoutePickerView is the right UI for 'cast audio elsewhere'; for
'pick how I hear / talk' (VoIP) the right primitive is direct
AVAudioSession calls.

Fix: replace audio_router with audio_session 0.2.3 (Ryan Heise,
verified publisher, 865k downloads, MIT). audio_session exposes:

  * AVAudioSession.availableInputs \u2014 enumerate every real input
    port: builtInMic, bluetoothHfp, bluetoothA2dp, headsetMic
    (wired), usbAudio, carAudio, airPlay.
  * AVAudioSession.currentRoute \u2014 .inputs + .outputs of the
    active route.
  * AVAudioSession.setPreferredInput(port) \u2014 switch the input
    (HFP / wired / USB / car audio also move output to themselves).
  * AVAudioSession.overrideOutputAudioPort(.speaker | .none) \u2014
    toggle built-in speakerphone vs receiver/earpiece.
  * AVAudioSession.routeChangeStream \u2014 live notifications when
    the user plugs / unplugs / connects a device while the picker
    is open.

This is exactly the same primitive Discord, WhatsApp, FaceTime
use for their VoIP audio chooser. No native UI plugin needed.

New widgets in voice_compact.dart:

  * _AudioOutputTile: shows the active output port name (Speaker /
    iPhone / AirPods / 'Phil's Wired Headset' / etc.) with the
    matching icon. Subscribes to routeChangeStream for live
    updates. Tap opens _AudioOutputPickerSheet.

  * _AudioOutputPickerSheet: bottom sheet with 'Choose audio' title
    and a Discord-style list:
      - Speaker          (volume_up)
      - iPhone           (phone_in_talk; the receiver/earpiece)
      - <BT name>        (bluetooth_audio)
      - <Wired headset>  (headset)
      - <USB / Car>      (usb / directions_car)
    Selected row is highlighted + has a check mark. Tap routes:
      - Speaker  -> overrideOutputAudioPort(.speaker)
      - iPhone   -> overrideOutputAudioPort(.none) + setPreferredInput(builtInMic)
      - External -> overrideOutputAudioPort(.none) + setPreferredInput(port)

  * _PickerRow: shared row widget with selected/check styling.

AppDelegate.swift is unchanged: the manual AVAudioSession
.setCategory(playAndRecord / .voiceChat) we already do at launch
(0466000 / 4ee2b38) is fully compatible with audio_session \u2014 the
plugin only adds Dart-side accessors over the same underlying
AVAudioSession singleton.

Removed l10n keys not used anymore (audioRouteUsb was already gone).
Kept audioRouteSpeaker / Receiver / Bluetooth / WiredHeadset /
CarAudio / Airplay / Unknown \u2014 all still used by the new picker.

flutter analyze: 6 pre-existing Radio deprecation infos (unchanged).
flutter build ios --release --no-codesign: 54.9 s, Runner.app
30.4 MB (+200 KB vs audio_router build).
2026-05-16 20:53:00 +08:00
EdisonJwa 0c1fd1c2e3 fix(ui,ios): use TextField.onTapOutside instead of outer GestureDetector (fixes _dependents.isEmpty assert + keyboard lag)
User reported 'package:flutter/src/widgets/framework.dart line 6268
_dependents.isEmpty is not true' assertion AND the persistent
keyboard lag.

Root cause analysis:

framework.dart:6268 is the assertion in
InheritedElement.debugDeactivated() that fires when an
InheritedElement is being deactivated while descendants still
depend on it. This fires in debug builds; release builds skip it.

The previous tap-outside-to-dismiss implementation used:

  return GestureDetector(
    behavior: HitTestBehavior.opaque,
    onTap: () => FocusScope.of(context).unfocus(),
    child: Column(...),
  );

The FocusScope.of(context) call subscribes this GestureDetector's
Element to the _FocusScopeMarker InheritedWidget on every build.
During the modal-sheet-pop / Navigator-deactivate sequence, the
ancestor _FocusScopeMarker InheritedElement can deactivate before
the descendant GestureDetector clears its dependency, tripping the
debug assertion.

This was the same root cause as the keyboard lag: an outer
GestureDetector in the gesture arena ahead of the TextField's own
recognizer ALWAYS interferes, either via the opaque-vs-translucent
arena race (causing lag) or via the InheritedWidget subscription
race (causing the assert).

Fix: remove the outer GestureDetector entirely. Use the built-in
TextField.onTapOutside callback added in Flutter 3.10+ instead:

  TextField(
    ...
    onTapOutside: _onTapOutside,
  )

  void _onTapOutside(PointerDownEvent _) {
    FocusManager.instance.primaryFocus?.unfocus();
  }

Why this is strictly better:

  * FocusManager.instance is a global singleton with NO
    BuildContext dependency. No InheritedWidget is subscribed; no
    _dependents map grows; no race possible on dispose.

  * TextField.onTapOutside uses the framework's TapRegion machinery
    internally. Taps inside the field's TapRegion don't fire the
    callback; only true outside-region taps do. Zero gesture-arena
    interference with the TextField's own tap recognizer \u2014
    keyboard appears synchronously on the first tap with no lag.

  * iOS's default _EditableTextTapOutsideAction at
    flutter/widgets/editable_text.dart:6748-6755 is intentionally a
    no-op for touch on mobile (Apple convention: dismiss via Done
    or swipe-down). Our explicit onTapOutside override is the
    correct way to opt into tap-outside-to-dismiss on mobile
    without fighting iOS conventions or the framework's gesture
    arena.

Wired onTapOutside on all three connect form fields (host /
nickname / password).

flutter build ios --release --no-codesign: 20.5 s, Runner.app
30.2 MB (unchanged). flutter analyze: 6 pre-existing Radio
deprecation infos (unchanged).
2026-05-16 19:18:54 +08:00
EdisonJwa 6955547cec fix(ui,ios): switch tap-outside-unfocus to HitTestBehavior.opaque (eliminate keyboard race lag)
User reported on iPhone iOS 18: 'still a bit lag and stuck' after
the _kickFocus removal (1ceb47f). Web research (flutter/flutter
keyboard performance issues) and code review of GestureDetector
hit-test semantics identified the remaining race.

Root cause:

The outer GestureDetector wrapping the connect form Column was
using HitTestBehavior.translucent. Translucent semantics dispatch
the pointer event to BOTH the GestureDetector AND any descendant
hit-test target. So when the user tapped a TextField, two things
fired simultaneously:

  1. GestureDetector.onTap -> FocusScope.of(context).unfocus()
     This drove the keyboard *down* via the platform TextInput.hide
     side effect of clearing focus.

  2. TextField's own TapGestureRecognizer -> EditableText.attach
     This drove the keyboard *up* via TextInput.show.

The two CAAnimations on iOS 18 raced each other inside the same
UIKit transaction, producing:
  * 500-1000 ms of visible 'thinking' before the keyboard appeared
    (one full slide-down + one full slide-up).
  * Occasional 'stuck' state where the keyboard never came back up
    because UIResponder.becomeFirstResponder was called before
    resignFirstResponder finished.

Fix: switch to HitTestBehavior.opaque. With opaque:
  * The GestureDetector still receives hit-test results for its
    entire bounds (so taps on empty padding between fields still
    reach onTap).
  * But the gesture arena routes a tap that lands on a TextField
    to that TextField's recognizer ONLY \u2014 the outer
    GestureDetector loses the arena and its onTap does not fire.
  * Net: tapping a field is exactly equivalent to having no outer
    GestureDetector at all (no race, no lag, no stuck). Tapping
    empty space still dismisses the keyboard cleanly.

This is the canonical pattern that several Stack Overflow answers
and the GestureDetector dartdoc recommend for 'tap-outside-to-
dismiss-keyboard'. translucent is for cases where you want both
the outer and inner to react simultaneously (rare).

flutter build ios --release --no-codesign: 29.0 s, Runner.app
30.2 MB.
2026-05-16 18:59:13 +08:00
EdisonJwa 1ceb47f1e3 fix(ui,ios): remove _kickFocus microtask refocus (500-1000 ms keyboard lag)
User reported: 'amount need wait 500ms - 1s if i click input field
-> then keyboard popup'. The 79f8360 _kickFocus workaround was the
source of the lag.

Root cause of the lag:

  void _kickFocus(FocusNode node) {
    if (node.hasFocus) node.unfocus();
    Future.microtask(() {            // <-- this microtask
      if (!mounted) return;
      node.requestFocus();
    });
  }

The Future.microtask deferral forces EditableText's attach-to-
TextInput path to wait one frame past the user's pointer-up. iOS
26's keyboard slide-up animation then dovetails into that extra
frame in a way that adds another 200-800 ms before the keyboard
actually appears on screen. Net latency: ~500-1000 ms.

Fix: remove _kickFocus entirely. Rely on:

  1. TextField's native onTap path (no onTap override = no
     deferral, no microtask hop, no SystemChannels race).

  2. The tap-outside-to-unfocus GestureDetector wrapping the
     connect form Column (7c62d14) which already guarantees the
     FocusNode is in the unfocused state when the user taps any
     field, because any prior keyboard dismissal (tap outside / tap
     a sibling field) goes through FocusScope.of(context).unfocus().

This means the FocusNode is always in a clean false state when a
TextField gets tapped, so EditableText's own attach path can fire
synchronously on the first frame and the keyboard appears
instantly.

The full _kickFocus implementation is retained as a code comment
above the connect-form's build() for documentation and quick
re-introduction should iOS regress again. The flutter/flutter#181474
issue (the underlying iOS 26 bug) remains open, so the comment
documents the canonical workaround if needed.

flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
(unchanged). flutter build ios --release --no-codesign: 20.4 s,
Runner.app 30.2 MB.
2026-05-16 18:55:00 +08:00
EdisonJwa 7c62d14dd8 feat(ui,ios): inline mode+tail into voice sheet, fix Unknown audio route, tap-outside unfocus
Three user-reported issues addressed at once.

1. Audio output displaying as Unknown on iOS

The route tile only set _device from currentDeviceStream events,
which fire on route *changes*. On first sheet open with no route
change yet, _device was null \u2192 _deviceLabel fell through to
audioRouteUnknown.

Fix: query AudioRouterPlatform.instance.getCurrentDevice() in
initState before attaching the stream listener. Plugin returns the
current AVAudioSession route synchronously (well, via Future) so
the tile renders Speaker / iPhone receiver / AirPods / etc.
immediately on first open. Errors swallowed \u2014 the stream remains
authoritative for subsequent updates.

2. 'Adjust mode & release tail' too deep (chip \u2192 modal \u2192 button \u2192 dialog)

Inlined the mode radio buttons and release-tail slider directly
into the voice modal sheet. Dropped the OutlinedButton 'Adjust'
trigger and the nested VoiceSettingsDialog dispatch entirely on
mobile.

Modal sheet is now a single-screen control panel:

  Title 'Voice'
  --------
  Audio output: <current route>           >    (iOS/Android only)
  --------
  Transmit mode
    \u25c9 PTT
    \u25cb Continuous
    \u25cb Voice activity (Coming soon)             (disabled)
  --------
  Release tail                  200 ms
  [\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501]                       (0\u20131000 ms, step 50)
  Bound key: F                                  (desktop only)
  --------
  Level meter
  TX/RX frame counts
  PTT capability badge                          (desktop only)

VoiceSettingsDialog is retained for the wide-mode VoiceBar
'configure' button (desktop entrypoint) and the PTT-bind flow, so
desktop UX is unaffected.

New widgets: _VoiceSheetBody (StatefulWidget with local _mode +
_tail), _ModeRow (RadioListTile-shaped row with optional disabled
state for VoiceActivity). New API on showVoiceDetailsSheet:
onModeChanged + onReleaseTailChanged callbacks (replace
onAdjustVoiceSettings). Wiring in main.dart writes through to
rust.setTransmitMode / rust.setReleaseTailMs and mirrors _state.

l10n: dropped voiceAdjustSettings (en + zh). Added voiceBoundKeyLabel
(en + zh) for the desktop-only bound-key row.

3. iOS first-tap-keyboard regression (flutter/flutter#181474)

The 79f8360 _kickFocus workaround (unfocus + microtask refocus on
every TextField.onTap) was kept, but extended with a tap-outside-
to-unfocus GestureDetector wrapping the connect form Column. This
guarantees the FocusNode is in the unfocused state when the next
field tap arrives, so the focus transition is always false\u2192true
on first tap.

GestureDetector(HitTestBehavior.translucent, onTap: unfocus) is the
canonical pattern recommended in the flutter/flutter#181474 thread
+ several older iOS keyboard issues. Translucent behaviour means it
catches taps on the column padding / empty regions without
swallowing taps on the TextFields themselves (those have
onTap: _kickFocus already).

flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.9 s, Runner.app 30.2 MB (unchanged).

Awaiting iPhone retest to confirm all three fixes.
2026-05-16 18:44:48 +08:00