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.
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).
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/
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.
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.
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.
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'.
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.
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.
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.
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).
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).
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.
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.
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.
User reported: 'input field still need to click twice' on iPhone +
'a bit lag after keyboard pop up'. The previous Listener-based
approach (020be77 \u2192 23bd1c7) actively made both symptoms worse.
Root cause is a confirmed open Flutter framework bug:
flutter/flutter#181474 \u2014 [iPadOS] Keyboard is dismissed, but the
TextField keeps focus, causing subsequent taps not to trigger
keyboard presentation.
Open, P2, triaged-text-input, platform-ios, e: OS-version specific.
Reported on iPadOS 26.2 + Flutter 3.38.1 in Jan 2026 by
Crazymuyang.
Reproduction matches our symptom exactly: iOS 26 dismisses the soft
keyboard (e.g. on tap-outside, or in some fresh-launch states), but
the EditableText's FocusNode keeps hasFocus = true. Because the
node is already focused, the next user tap is a no-op from the
focus system's perspective, so the platform TextInput channel is
never re-opened and iOS keeps the soft keyboard hidden until a
second tap finally triggers an explicit re-focus path.
Why our previous attempts failed:
* 020be77 wrapped each TextField in a Listener that called
requestFocus pre-arena. That's racing the wrong layer \u2014 it
doesn't help when the bug is 'node is already focused, so
requestFocus is a no-op'.
* 23bd1c7 added SystemChannels.textInput.invokeMethod('TextInput.show')
to the same Listener. This forced the keyboard up but raced
EditableText's own attach path, producing the post-attach
typing lag the user reported.
Fix: the community-recommended workaround in the issue thread \u2014
on every TextField tap, **unfocus first, then re-request focus on
the next microtask**. This forces a real false\u2192true focus-change
transition that re-opens TextInput on the first tap. No platform
channel races, no gesture-arena fighting, no Listener wrappers.
void _kickFocus(FocusNode node) {
if (node.hasFocus) node.unfocus();
Future.microtask(() {
if (!mounted) return;
node.requestFocus();
});
}
TextField(
onTap: () => _kickFocus(_hostFocus),
...
)
Wired into all three connect-form fields (host / nickname /
password). Removed the now-redundant _focusOnTap Listener helper.
No-op on hosts where #181474 doesn't reproduce \u2014 the unfocus call
is a no-op when the node isn't focused, and the microtask
requestFocus is what TextField would have done anyway via its own
TapGestureRecognizer.
flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.6 s, Runner.app 30.2 MB.
User reported: 'input field still need to click twice' on iPhone.
The 020be77 Listener + requestFocus() approach was insufficient.
Root cause analysis:
* Flutter's EditableText opens the platform TextInput method
channel (which is what slides the iOS soft keyboard up) only
after a TapGestureRecognizer wins the gesture arena.
* Our outer Listener calls node.requestFocus() pre-arena. That
flags the FocusNode as focused in Flutter's focus tree, but
does NOT open the TextInput channel \u2014 so iOS keeps the OS
keyboard hidden until EditableText's own tap recognizer wins
on a second tap.
* requestFocus on its own is therefore a no-op for the user
visually: the cursor + caret appear briefly but the keyboard
stays down.
Fix: in the Listener.onPointerDown handler, additionally invoke
'TextInput.show' on SystemChannels.textInput. This is the same
private platform RPC EditableText calls internally on attach;
forcing it ourselves slides the keyboard up regardless of arena
state.
Belt-and-braces with the existing requestFocus() guarantees
keyboard-on-first-tap on iPhone / iPad and is harmless on:
* Android (the platform ignores the redundant show call when
the keyboard is already up),
* Linux / Windows / macOS desktop (no soft keyboard exists; the
method channel handler returns success without doing
anything).
No new imports needed \u2014 SystemChannels is already in
package:flutter/services.dart (imported for FilteringTextInputFormatter).
flutter build ios --release --no-codesign: 20.7 s, Runner.app
30.2 MB.
Two user-reported issues addressed:
1. 'on supported devices such as iphone or android user should be
able to select audio device such as speaker or airpods or phone'
2. 'the bottom folder is duplicated with the app bar settings'
Issue 1 \u2014 audio output route picker:
Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:
* iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
Control Center's audio chooser. Lists Speaker / iPhone receiver /
AirPods / connected Bluetooth devices / AirPlay / CarPlay.
System manages the device list; we don't have to track route
changes manually.
* Android (post-rc.8 when we wire the platform): Material Design 3
dialog backed by AudioManager.setCommunicationDevice() with
SCO Bluetooth + USB headsets filtered for VoIP.
The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit 0466000.
The new _AudioOutputTile widget in voice_compact.dart subscribes to
AudioRouter.currentDeviceStream so the row label + icon auto-update
when the user plugs in headphones, connects AirPods, etc. \u2014 no
manual KVO observation needed.
Tile is mobile-only (Platform.isIOS || Platform.isAndroid). Desktop
hosts continue to use the system mixer; the tile is hidden.
Plugin caveat: the published enum AudioSourceType has no .usb
variant (despite README mentioning USB support). We map only the
seven real enum cases: builtinSpeaker / builtinReceiver / bluetooth /
wiredHeadset / carAudio / airplay / unknown.
Issue 2 \u2014 collapse voice controls into the single modal sheet:
The AppBar gear icon (Icons.tune) that opened VoiceSettingsDialog
was removed. It duplicated the configuration entry point that the
status chip \u2192 modal-sheet path already provides, and the user found
that duplication confusing on a phone-narrow screen where AppBar
real estate is precious.
The voice modal sheet (showVoiceDetailsSheet) is now the **single**
voice-controls surface on mobile, with layout (top to bottom):
1. Audio output route picker tile (iOS / Android only) \u2014 new.
2. Mode + bind / release-tail recap (display only).
3. 'Adjust mode & release tail' OutlinedButton that closes the
sheet and opens the same VoiceSettingsDialog the gear icon
used to open. One config form, not two.
4. Mic level meter.
5. TX / RX frame counts + mic state.
6. PTT capability badge (desktop only).
Sheet title renamed from 'Voice settings' (which collided with the
gear-icon tooltip) to 'Voice'. New l10n keys: voiceSheetTitle,
voiceAdjustSettings, audioOutputLabel, audioRoute{Speaker,Receiver,
Bluetooth,WiredHeadset,CarAudio,Airplay,Unknown}. en + zh translated.
Build: flutter build ios --release --no-codesign clean, 50.5 s,
Runner.app 30.2 MB (+200 KB from audio_router). flutter analyze
clean (6 pre-existing Radio.groupValue deprecation infos in
voice_settings.dart, unchanged).
The server-host TextField on the connect form accepts a hostname or
hostname:port pair (e.g. kr.teamspeak.app:9987). Two improvements:
1. keyboardType: TextInputType.url surfaces '.', '/', ':' on the
primary on-screen keyboard plane so the user does not have to
switch to the symbols pane mid-address. Matches iOS Safari's
URL bar.
2. textCapitalization.none + autocorrect/enableSuggestions=false
prevents iOS from auto-capitalising the first letter or
'correcting' 'kr.teamspeak.app' to something else.
3. inputFormatters belt-and-braces:
* deny whitespace (handles tab-indented paste)
* lowercase pipeline (handles uppercase paste)
4. Visual affordances: prefix dns icon + 'host[:port]' hint.
Nickname field intentionally unchanged \u2014 may contain unicode,
mixed case, spaces.
flutter build ios --release --no-codesign: 30.7 s, Runner.app
30.0 MB. flutter analyze clean (6 pre-existing deprecation
warnings on Radio.groupValue/onChanged).
Two distinct fixes prompted by user reports from the iPhone build:
1. 'strange text on Chanora (RFLOWED BY)' \u2014 the Flutter debug
overlay's 'OVERFLOWED BY N PIXELS' strip was appearing next to
the AppBar title because the title Row ('Chanora' + channel
pill) plus 5-6 trailing IconButton actions exceeded a typical
iPhone AppBar width. User saw the strip clipped to '...RFLOWED
BY...' since only its end fit on screen.
_AppBarTitle now drops the 'Chanora' label on narrow widths
(<840 dp). Title shows only the channel pill when in voice
channel; the user already knows they're in Chanora because
they just opened it. Wide widths (tablet/desktop, >= 840 dp)
keep the full 'Chanora \u00b7 #channel-pill' title because there's
room. Eliminates the overflow.
Note: the OVERFLOWED-BY strip only renders in debug builds
anyway; release builds suppress the overlay. But the
underlying Row overflow was a real layout bug worth fixing.
2. First-tap TextField still failed on iPhone after the earlier
FocusNode + TextField.onTap fix. Root cause: TextField.onTap
fires AFTER the gesture-arena resolves, so if the enclosing
SingleChildScrollView wins the arena (which it does on iOS
for the very first tap), the focus request never fires.
Wrap each connect-form TextField in a Listener with
HitTestBehavior.translucent and onPointerDown: requestFocus.
Listener fires synchronously on PointerDownEvent BEFORE arena
resolution, so even if the scrollable would have won the arena
we have already grabbed focus. Translucent means the pointer
ALSO propagates down to the TextField so its normal touch
handling still runs (text selection / cursor placement).
_focusOnTap helper added; wraps all three TextFields
(host, nick, password).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 18.4 s, Runner.app
30.0 MB.
Restructure the narrow / mobile body layout around three principles
(Hoober thumb-zone research validated, Material 3 components):
1. Channel tree gets ~85% of the screen height.
2. Live voice state is always visible in a 2-line status chip
above the PTT button.
3. PTT button is wide, bottom-anchored (thumb-natural lower zone).
4. Frequent toggles (mic mute, headset mute, voice settings) live
in the AppBar so they don't compete with the channel tree.
5. Non-essential live readouts (level meter, TX/RX, capability
badge) live in a modal sheet opened by tapping the status chip
-- progressive disclosure.
apps/chanora_flutter/lib/widgets/voice_compact.dart (new file)
* VoiceStatusChip: 2-line live readout. Line 1 = mode + bind hint;
Line 2 = release-tail + 'Mic on/off'. Tap opens
showVoiceDetailsSheet.
* VoicePttButton: 56 dp wide bottom-anchored Push to Talk button.
Same touch-and-hold gestures as the previous _PttHoldButton.
* showVoiceDetailsSheet: modal bottom sheet with mode recap +
bind/tail hint + level meter + TX/RX counts + PTT capability
badge (desktop-only).
apps/chanora_flutter/lib/main.dart
* AppBar gains mic-mute, headset-mute, voice-settings icons when
in voice channel AND MediaQuery width < 840 dp (mobile only).
Wide mode keeps these controls inside the existing VoiceBar
widget unchanged.
* AppBar title becomes a Row of 'app name + channel chip' when
in voice channel.
* Narrow-mode body restructured: Expanded(channelTree) +
VoiceStatusChip + VoicePttButton (latter only when in voice
channel AND PTT mode). The old narrow-mode VoiceBar is gone;
wide-mode VoiceBar is unchanged.
* New _onOpenVoiceDetailsSheet handler bridges the chip-tap to
showVoiceDetailsSheet.
* New top-level helper _isTouchOnlyPttHost mirrors the helpers
in widgets/voice_bar.dart and widgets/voice_settings.dart so
the AppBar + narrow-mode chip can branch consistently.
apps/chanora_flutter/lib/l10n/app_en.arb
apps/chanora_flutter/lib/l10n/app_zh.arb
apps/chanora_flutter/lib/l10n/generated/* (regenerated)
* New string voicePttHoldHint = 'Hold the button' / '按住按钮'.
Surfaced in line 1 of VoiceStatusChip on touch-only hosts and
in the modal sheet's PTT line where the desktop equivalent
would name a bound key.
Wide-mode (>= 840 dp) layout intentionally unchanged so the
signed-off rc.8 desktop verification still applies.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Local cargo + flutter analyze pass; Mac was offline at commit
time so iOS device build verification is pending the next sync.
User report: 'every first time to tap to input box nothing
happened'. Symptom is iPhone-specific: the very first tap on any
of the Host / Nickname / Password text boxes in the connect form
fails to focus + open the keyboard. The second tap on the same
field works.
Two distinct causes, both addressed:
apps/chanora_flutter/lib/main.dart
The connect form lives inside a SingleChildScrollView. Flutter
on iOS has a long-standing issue (flutter#19027) where the
enclosing Scrollable's gesture-arena participant absorbs the
first tap as a possible scroll-intent, leaving the TextField
unfocused; the second tap reaches the field because the
scrollable has already declined to handle a drag.
Fix: _ConnectForm converted from StatelessWidget to
StatefulWidget so it can own FocusNodes for the three text
fields. Each TextField gains:
* focusNode: <its own FocusNode>
* onTap: () => focusNode.requestFocus()
— forces focus on tap-down regardless of arena outcome
* textInputAction: TextInputAction.next (host, nick) /
TextInputAction.done (password) for return-key flow
* autocorrect: false, enableSuggestions: false
— these are server-host / nickname / password fields, the
iOS auto-correct + suggestion bar is wrong for all three.
Also set keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior
.onDrag on the SingleChildScrollView so the keyboard hides
when the user starts scrolling the bookmark list below.
apps/chanora_flutter/ios/Runner/AppDelegate.swift
AVAudioSession.sharedInstance().requestRecordPermission was
fired synchronously from didFinishLaunchingWithOptions. The
permission alert can race with iOS's text-input subsystem
initialisation: if the alert appears before the keyboard
layer finishes wiring up, subsequent text-field focus
requests are dropped silently.
Fix: DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) to
defer the permission request until ~1 s after the app shell
is on screen. Long enough for iOS's text-input layer to fully
initialise; short enough that the user reads the prompt
before tapping a field.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 26.8 s clean
(Runner.app 29.8 MB).
User report from sideloaded iPhone build, in order of priority:
#4 'Could not join channel: audio: audio backend:
build_output_stream: The requested stream configuration is
not supported by the device.'
Cause: we forced cpal::BufferSize::Fixed(2048) on the output
and input streams unconditionally on non-Linux. iOS CoreAudio
RemoteIO units reject arbitrary buffer-size requests with that
exact error. Windows WASAPI needs the pinning for shared-mode
jitter, but macOS / iOS do not.
Fix: cfg-gate Fixed(2048) to target_os = 'windows'; everywhere
else use BufferSize::Default and let the platform HAL pick.
crates/chanora_audio/src/engine.rs.
#5 'Could not join channel: invariant violated:
voice_in already taken'
Cause: start_audio tore down the old engine BEFORE attempting
to construct the new one, and consumed voice_in (an mpsc
Receiver that can only be taken once) early. When the new
engine failed mid-construction (e.g. because of #4 above) the
session was left with: no audio engine, voice_in consumed,
no way to retry without reconnect. The second voice_join
attempt surfaced the invariant message.
Fix: build the new engine BEFORE tearing down the old. Only
swap state.audio if construction succeeded. crates/chanora_
core/src/lib.rs::ChanoraSession::start_audio. Additionally
added a put_voice_in helper to the protocol adapter (
crates/chanora_protocol/src/adapter.rs) for a future
broadcast-channel migration; the helper is unused on the
immediate fix path but documents the intent.
#3 'permission request would better on first open'
Cause: AVAudioSession only triggers the mic-permission
prompt the first time it tries to record. We never recorded
until voice_join, so the prompt fired then.
Fix iOS: AVAudioSession.sharedInstance().requestRecordPermission
in AppDelegate.swift::application(_:didFinishLaunchingWithOptions:).
Fix macOS: AVCaptureDevice.requestAccess(for: .audio) in
macos/Runner/AppDelegate.swift::applicationDidFinishLaunching.
Both run non-blocking; user can deny without crashing app
launch, and voice_join then surfaces a clearer downstream
error when the engine fails to open the input device.
#1 + #2 'one-column upper takes too much space; Push to Talk
button at bottom would be better'
Layout rework for narrow-mode (single column, mobile shape):
- Flipped the stacking order in main.dart so Voice Bar moves
to the BOTTOM of the body and the channel tree (Expanded)
fills above. Wide-mode (Row, >= 840 dp) layout unchanged.
- Inside the Voice Bar on touch-only hosts, moved the
on-screen Push to Talk button to be the LAST element of
the Voice Bar (was Row 3). Order now: pill + mutes, mode
badge + settings, level meter, stats line, release-tail
caption, PTT button. The button is closest to the user's
thumb when the Voice Bar is pinned to the bottom of a
narrow-layout screen.
#6 'remove right top debug badge'
debugShowCheckedModeBanner: false on the MaterialApp.
Release builds never showed it anyway; this only affects
local dev / debug builds.
#7 'what does the refresh button use for? nothing happened'
Removed. The snapshot updates via BridgeEvent::SnapshotChanged
are pushed from the bridge — a manual rust.snapshot() call
was redundant. Now only the Diagnostics + Disconnect actions
remain in the AppBar trailing row when connected.
#8 'Bind Key related function should not be added to a mobile
platform'
widgets/voice_settings.dart: bind-key OutlinedButton is now
#cfg'd out when Platform.isIOS || Platform.isAndroid. The
release-tail slider stays because it still applies to the
on-screen PTT button. Capability badge in voice_bar.dart
also hidden on mobile (it would always show L0Focused which
is redundant with the visible on-screen button).
Tests + analyze: chanora_audio 34/0/0 on macOS, workspace 78/0/1
on Linux; flutter analyze clean (6 pre-existing Radio.groupValue
infos). flutter build ios --release --no-codesign: 28.8 s clean
(Runner.app 29.9 MB).
iOS / iPadOS / Android have no hardware keyboard for the user to
bind a PTT key on. Up to now the VoiceBar showed only a
'Push to talk: bound key —' hint that didn't lead anywhere usable.
Add a touch-and-hold on-screen PTT button rendered only on
touch-only platforms (Platform.isIOS || Platform.isAndroid; web
hosts and desktop continue to use the hardware-key path
unchanged).
apps/chanora_flutter/lib/widgets/voice_bar.dart:
* New module-private `_isTouchOnlyPttHost` predicate.
* VoiceBar gains an `onPttHeldChanged: ValueChanged<bool>`
constructor param. Desktop callers wire it but never invoke it
because the button is not rendered there.
* Row 3 (the PTT-only secondary content) now branches:
- on touch-only hosts -> renders the new `_PttHoldButton` plus
a small release-tail hint underneath
- on hardware-keyboard hosts -> renders the same bound-key +
release-tail one-liner as before, unchanged.
* New `_PttHoldButton` StatefulWidget. Uses a single
GestureDetector covering onTapDown / onTapUp / onTapCancel /
onPanDown / onPanEnd / onPanCancel so the held edges fire on
finger-down and the released edge fires when the user lifts
OR drags off OR another gesture in the arena wins. Visual
feedback mirrors the level-meter active flag.
apps/chanora_flutter/lib/main.dart:
* New `_onOnscreenPttHeldChanged(bool held)` method that calls
`rust.setPtt(active: held)`. The bridge's set_ptt routes the
edge through the same release-tail timer + transmit-mode
selector that desktop hardware keys use (SDD-096 / SAD-083),
so behaviour parity is preserved.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: clean (Runner.app 29.9 MB).
DEC-025: iPhone + iPad + Android in scope; this commit makes PTT
mode actually usable on those platforms. The 'Focused' capability
badge wording in ios-p0-acceptance.md / ipad-p0-acceptance.md
already documents the on-screen button as the only PTT input;
this commit makes that documentation true.
Two related VoiceBar / scaffold issues on wide windows:
1. Banner placement
The 'not production ready' tertiaryContainer banner sat full-width
above the body Column. In wide layouts (>=840 dp) where the connected
view splits into Voice Bar (320 dp) + channel tree (Expanded), the
banner spanned both columns and dwarfed the channel-tree pane.
Rework: wrap the body in an outer LayoutBuilder so the placement
decision can read bodyConstraints.maxWidth. When wide AND connected
AND snapshot != null, render the banner inside the left 320 dp
SizedBox above the VoiceBar. In every other state (narrow, idle,
connecting) the banner stays pinned full-width at the top.
2. Channel-name pill overflow
The Container holding the channel pill had no width constraint and
Text(channelName) had no overflow handling. Long channel names made
the pill extend past the column's 320 dp; mute icons slid under the
adjacent channel tree.
Rework: pill wrapped in Flexible(flex: 100, fit: FlexFit.loose);
inner Text gets maxLines: 1, overflow: TextOverflow.ellipsis,
softWrap: false. Spacer keeps default flex 1; the 100:1 ratio means
short names hug their intrinsic width and long names take ~99% of
the remaining space then ellipsize. Mute icons stay pinned right.
flutter analyze: clean (6 pre-existing Radio.groupValue infos only).
Five user-reported defects + one auto-test regression-catcher.
== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.
Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.
Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.
New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.
== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.
Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.
Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
carries the canonical TS3 error code per the official catalogue
at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
The connection-task loop holds a pending_moves HashMap keyed by
MessageHandle, services StreamItem::MessageResult by looking up
and resolving the reply with either Ok or the typed
ServerRejected.
* Pending entries have a 3 s deadline so a server that never
replies doesn't leak the reply channel — expired entries fall
back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
full snapshot poll), still polls for confirmation as a
belt-and-braces fallback for legacy servers; on poll failure
emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
localised message selected by error code (channelJoinFailed*
l10n entries). 6 known codes mapped to specific messages
(insufficient permission, wrong password, channel full,
family limit, private channel, timeout); everything else
falls back to the server-supplied generic message.
== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.
Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.
== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.
Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).
== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.
Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
+2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
1. Voice Bar 'Leave voice' button removed entirely. TeamSpeak users
are always in some channel; Discord/Mumble-style leave is the
wrong model. To stop being heard / hearing, mute mic / speaker.
To physically move, tap a different channel. The voiceLeave
bridge call + _onLeaveVoice stay as dead code for now (marked
unused) so existing tests/integrations don't break.
2. voice_join now confirms the move actually applied server-side
by polling the snapshot for up to 1.5 s and matching our own
client's channel against the requested one. If the server
rejected the move (no permission, wrong password, channel
full), voice_join rolls the selector back to in_channel=false
and returns Err so the UI surfaces the failure instead of
showing a fake 'joined' state.
3. ServerSnapshot + BridgeSnapshot gain own_client_id so the UI
can identify our row without name-matching. find_own_in reads
it directly.
4. set_self_muted now also clamps the TransmitModeSelector's
hard_mute when input is muted server-side. Without this, the
Opus encoder kept producing frames after setInputMuted(true),
tsclientlib refused each one with 'Sending audio while muted',
and the log grew to 200 MB on the Korean host.
5. tsclientlib WARN spam suppressed via tracing filter
(tsclientlib=error). Belt-and-braces on top of fix 4.
6. Log file is now rotated at every launch (not just when >4 MiB).
Two generations kept: chanora.log.1 (previous) and
chanora.log.2 (the one before). The bug that produced 200 MB
files was a chatty subsystem flooding a single session; the
per-launch rotate keeps disk use bounded by what one session
can produce in its lifetime.
Bonus Windows fix (separate from the six but found in the same
log): the Raw Input + Hook backends now signal readiness BEFORE
blocking on GetMessageW. Previously init_tx.send was called after
the loop returned (i.e. on WM_QUIT, which never happens during
arming), so the main thread's 2 s readiness probe always timed
out and the backend reported L0Focused even when registration
succeeded. Both run_raw_input_loop and run_hook_loop now take an
init_tx parameter and call report!(true) right after a successful
registration, and report!(false) on every early-fail return.
cargo check --workspace: clean.
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).