42d0ca0953045d31c130d8cd769cfebda3d3b88d
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
42d0ca0953 | fix(android): polish app chrome and join guard | ||
|
|
bc70da51df | fix(android): derive current channel from snapshot | ||
|
|
3ae8e1ab77 | fix(android): adapt fold layout and diagnostics | ||
|
|
30ff955439 | fix(android): clear permission-owned hard mute | ||
|
|
eb1f4b895e | fix(android): remove cpal product wording | ||
|
|
210a6a0e11 | fix(android): unblock mic permission startup | ||
|
|
8c253f1d4d | feat(voice): harden Android audio and channel joins | ||
|
|
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. |
||
|
|
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).
|
||
|
|
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) |
||
|
|
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/ |
||
|
|
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. |
||
|
|
7a59f5b9a1 | feat(ios,p0): iOS P0 platform, audio fixes, channel UX | ||
|
|
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
|
||
|
|
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. |
||
|
|
2f6cdd3ad4 |
Revert "diag(ios,audio): log AVAudioSession state + route changes around picker overrides"
This reverts commit
|
||
|
|
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'. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
(
|
||
|
|
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).
|
||
|
|
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 (
|
||
|
|
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 |
||
|
|
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
|
||
|
|
79f83604da |
fix(ui,ios): apply community workaround for flutter/flutter#181474 (iOS 26 keyboard stale-focus)
User reported: 'input field still need to click twice' on iPhone + 'a bit lag after keyboard pop up'. The previous Listener-based approach ( |
||
|
|
23bd1c7930 |
fix(ui,ios): force TextInput.show on first tap (keyboard-up-on-first-tap)
User reported: 'input field still need to click twice' on iPhone.
The
|
||
|
|
b8e9c8549e |
feat(ui,ios,android): audio output route picker + collapse voice controls into single modal
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
|
||
|
|
fa94b9438f |
feat(ui): URL-shaped keyboard + lowercase enforcement for server host
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).
|
||
|
|
020be77faf |
fix(ui,ios): AppBar OVERFLOWED-BY strip + first-tap TextField via Listener
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.
|
||
|
|
3b08ea9d32 |
feat(ui,mobile): Plan E voice UI -- status chip + bottom-anchored PTT + AppBar mutes
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.
|
||
|
|
7c0e5caa92 |
fix(ui,ios): first-tap on TextField now opens the keyboard
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).
|
||
|
|
d4c04b6a72 |
fix(ui,audio,ios,macos): eight P0 mobile fixes
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). |
||
|
|
278df25fd7 |
feat(ui,ptt): on-screen touch-and-hold PTT button for iOS / iPadOS / Android
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.
|
||
|
|
25b5bb3a6d |
fix(ui): wide-mode banner placement + channel-pill overflow
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). |
||
|
|
2511b24982 |
fix(audio,protocol,ui): TC-2.3 + TC-10 + TC-13 + channel tree hierarchy
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.
|
||
|
|
181b3368d4 |
fix(audio,voice,log): six P0 issues from Korean Windows test
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). |
||
|
|
bb3c82e2d5 |
chore(version): bump to v1.0.0-rc.8 (post-rc.7 v1 audio + PTT lifecycle work)
pubspec.yaml + _kAppVersion in main.dart were both still reading v1.0.0-rc.1 even though the branch has accumulated 50+ commits of post-rc.7 work (the v1 audio + PTT lifecycle redesign in SDD-094..097, plus DEC-029/030/031). The About dialog and the diagnostic export's app_version field both surfaced the wrong string. Aligned both to v1.0.0-rc.8+59 (build number is the current commit count on the branch). The Rust workspace version stays at 0.0.1-pre — it's an internal pre-release marker the diagnostic export carries as crate_version, not user-facing, and changing it cascades into every inheriting Cargo.toml for no benefit. The actual v1.0.0-rc.8 tag will land once the Korean Windows 11 human-side P0 acceptance pass closes; this commit aligns the strings the running build shows so the tester sees a consistent version while running the tests. |
||
|
|
6d4975bd6e |
fix(ptt,ui): Continuous mode no longer self-disables after 30 s; rename PTT label to Mic
Issue 1: in Continuous transmit mode the talk indicator turned
gray-out / mic disabled after ~30 s and could only be revived by
toggling mic mute. Root cause: SAD-079 MissedKeyUpWatchdog
subscribed to AudioTransmitGate.transmit_active and force-cleared
it after 30 s of true. In PTT mode this is correct (stuck key =
bug). In Continuous mode transmit_active is *supposed* to stay
true indefinitely; the watchdog assumption doesn't hold.
Fix: the watchdog now subscribes to a new ptt_held watch on the
TransmitModeSelector (the raw key-state input, not the resolved
gate). In Continuous mode ptt_held is never set true, so the
watchdog never fires. In PTT mode it still fires on a stuck
key-down as before. The session owns the watchdog (was on the
engine) so it survives engine restarts; it's spawned lazily on the
first start_audio.
MissedKeyUpWatchdog gains spawn_on_signal(rx, on_timeout, timeout)
alongside the existing spawn(gate, timeout) — old shape preserved
for backwards compat. run_watchdog generalised to take any
watch::Receiver<bool> + Box<dyn Fn() + Send + Sync>.
Two new tests:
- watchdog_on_signal_does_not_fire_when_ptt_held_stays_false
(the Continuous-mode regression test)
- watchdog_on_signal_fires_when_signal_stays_true
(the stuck-key case still fires)
Issue 2: the Voice Bar stats line said 'PTT on/off' even when the
user was in Continuous mode where no PTT key is involved. Renamed
to 'Mic on/off' (mode-neutral) and l10n-ised the on/off literal:
- en: 'Mic on' / 'Mic off'
- zh: '麦克风 开启' / '麦克风 关闭'
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(was 78, +2 watchdog tests).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
|
||
|
|
7596f8a9dc |
fix(ui,voice): display 'Space' (not blank) in bind dialog; cut PTT poll latency
Issue 1: tapping Space in the PTT binding capture dialog set _captured to LogicalKeyboardKey.space.keyLabel which is ' ' (a single space character), rendering as a blank string in the 'Captured: ' display. Same problem for Enter, Tab, Backspace, etc. — Flutter's keyLabel returns the printable representation, not a readable name. Added _displayLabelForKey() with a table that maps whitespace and common special keys to canonical English labels matching the entries in crates/chanora_audio/src/ptt_backends/windows_keymap.rs (so the bridge resolves to the right VK_* on Windows). Pure modifier keys (shift / ctrl / alt / meta / caps / num / scroll lock) return null so they don't accidentally bind on their own. Issue 2: physical key press -> 'PTT=on' in the Voice Bar lagged by up to ~500 ms because audioStats was polled at 500 ms intervals. The Rust-side transition is microsecond-fast; the visible delay is purely the Flutter poll interval. Cut to 80 ms (~12 Hz), well below the perceptual lag threshold. Adds ~12 small FFI calls per second, trivially cheap. A push-based BridgeEvent::TransmitActiveChanged would let us drop the poll entirely; noted as a follow-up. flutter analyze: clean (6 pre-existing Radio.groupValue infos). |
||
|
|
82b99ebcff |
feat(ui,voice): add speaker mute button to VoiceBar
Speaker (output) mute existed in the legacy _AudioControls widget and the rust.setOutputMuted bridge call but was lost when SDD-097 replaced _AudioControls with VoiceBar. Mic mute carried over; speaker mute did not. Wire it back: VoiceBar gains an outputMuted prop + onToggleOutputMute callback and renders a headset/headset_off icon next to the existing mic mute. main.dart wires the existing _toggleOutputMute handler (previously dead-code with // ignore: unused_element). The bridge call setOutputMuted already does both effects together: local engine silencer + server-broadcast ClientOutputMuted flag. l10n: rename voiceHardMuteLabel to 'Mute microphone'/'麦克风静音' to distinguish from the new voiceOutputMuteLabel 'Mute speakers'/ '扬声器静音'. flutter analyze: clean (6 pre-existing Radio.groupValue infos). |
||
|
|
64878e3a7d |
fix(ui,voice): consolidate to single Voice settings entry point
The capability badge had its own 'Configure' TextButton that opened the bind-key flow, while the Voice Bar's settings gear also reached bind-key through the settings dialog. Two paths, same destination — confusing and pointless duplication that the user flagged. Resolution: the gear is the only configuration entry point. The capability badge becomes information-only — it still shows the detected PTT level + backend and (for L0Focused) the info-icon explanation sheet, but no Configure button. The badge no longer takes or props. The Voice Bar drops the callback added in |
||
|
|
8cd919cffc |
fix(audio,storage,ui): allow PTT binding before audio is running
Save-binding before joining a voice channel used to return BridgeError.invalidCommand(audio not started) because the PttController only exists after start_audio runs and set_ptt_binding required a live controller. Users naturally want to bind their PTT key once on first launch, not every time they join a channel — fix: * chanora_storage::IdentityFileStore::set_ptt_binding / get_ptt_binding persist the privacy-safe binding triple (input_class, platform_key, key_label) into audio_meta.json next to transmit_mode and release_tail_ms. * ChanoraSession holds pending_binding: Arc<Mutex<Option<PttBinding>>>. set_ptt_binding now (1) persists to storage best-effort, (2) stashes into pending_binding, (3) forwards live to the controller only if one exists. No more AudioNotStarted. * init_storage loads the persisted binding into pending_binding so it survives app restarts. * start_audio applies pending_binding immediately after constructing the PttController so the first key-press after join already works. * supervisor_loop carries pending_binding and re-applies it after any reconnect-driven audio engine restart, so reconnects don't silently drop the hotkey. * New bridge call get_ptt_binding() -> (input_class, key_label) plus a matching Flutter _hydratePttBinding() in initState lets the Voice Bar show the user's saved hotkey label on launch (e.g. 'PTT: Space') before any voice channel is joined. cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored. flutter analyze: clean (6 pre-existing Radio.groupValue infos). FRB bindings regenerated. |
||
|
|
6a41a0b4db |
fix(ui,voice): five Voice Bar / settings UX bugs
1. Hard-mute now informs the server (setInputMuted) in addition to clamping the local TransmitGate. Without the server-side flag, other clients keep seeing us un-muted; without the local clamp a beat of in-flight audio leaks through. Drive both together so the mic icon and the actual silence land at the same time. 2. Split the badge's Configure affordance from the Voice Bar's 'Voice settings' gear. The gear opens the mode + release-tail dialog (onConfigure); the badge's configure opens the bind-key capture flow directly (new onBindKey). Previously both routed to the settings dialog, so 'Voice settings' and the badge's 'Configure' were the same screen — useless duplication. 3. Bind-key label is now PTT-only. The mode-badge row no longer prints 'PTT: Space' when Continuous / Voice Activity is selected. A new PTT-only secondary line carries the bound key plus the release-tail value together, hidden entirely for non-PTT modes. 4. Release-tail row is now PTT-only in BOTH the Voice Bar and the Voice settings dialog. The dialog previously kept the slider visible across all modes; switching to Continuous left the user staring at a control that did nothing. 5. PTT capability badge is now PTT-only. In Continuous and Voice Activity modes there is no key binding to surface a capability for, so the 'L0Focused (focused)' line + its info sheet and the Configure button disappear from the Voice Bar when the user isn't in PTT mode. All five fixes are pure UI; no Rust changes needed. flutter analyze remains clean (6 pre-existing Radio.groupValue deprecation infos). |
||
|
|
7f4874f4c0 | chore(bridge): regenerate FRB bindings for log_file_path_str | ||
|
|
ba444d94bd |
feat(audio,bridge,flutter): v1 audio + PTT lifecycle implementation (SDD-094..097)
Implement the SDD-094 / SDD-095 / SDD-096 / SDD-097 detailed designs
committed in
|
||
|
|
5c413ba199 |
fix(protocol): sort channels by TS3 linked-list order, not by numeric value
The TeamSpeak 3 protocol's per-channel `order` field is NOT a
numeric rank — it stores the ChannelId of the channel that should
appear immediately before this one within the same parent. The
previous chanora_protocol::adapter::build_snapshot sorted by
`order.0` as if it were a sequence number, producing
stable-but-arbitrary output that did not match TS3 client display
order. Surfaced on the Windows verification round as 'channel
sort in not correct'.
Replace the numeric sort with a linked-list walk per parent
followed by a root-first depth-first emission so the bridge
consumer receives a pre-ordered tree:
fn sort_channels_tree(&[&Channel]) -> Vec<&Channel>
fn sort_channels_tree_by<T>(&[&T], extract) -> Vec<&T>
fn emit_subtree<T>(by_parent, root_id, out, extract)
Defensive behaviour:
* Per-parent cycle guard so a malformed snapshot can't infinite-loop.
* Channels whose predecessor pointer is unreachable from
order=0 are appended at the end of their parent bucket sorted
by id (channel never silently disappears from the UI).
* Channels whose `parent` is not present anywhere in the tree
are appended at the very end sorted by id (orphan defence).
Unit tests cover the four shapes that broke real users:
* Single-parent linked list out of HashMap iteration order
* Disconnected predecessor (leftover-bucket fallback)
* Two-level tree (depth-first subtree emission)
* Two-channel cycle (no infinite loop, both channels emitted)
Also removes the now-redundant Dart-side numeric sort in
_SnapshotView.build(); Flutter trusts the pre-ordered server
list and would otherwise re-introduce the bug.
Verified on Linux: cargo test --workspace 59/0/3 (was 55 + 4 new
adapter tests), flutter analyze clean.
|
||
|
|
feceacfdad |
feat(flutter): surface bound PTT key label in capability badge (SDD-091 follow-up)
After a user saved a PTT binding through _PttBindingCaptureDialog,
the capability badge showed the resolved level + backend (e.g.
'PTT: L2WindowsRawInput (windows-raw-input)') but never told the
user which key they had actually bound. Reported on the Windows
verification round as 'do you think we should tell user what key
they have set and then they will know what to press'.
This commit caches the captured platform-neutral key label in
_BetaHomeState whenever _onConfigurePtt succeeds, and threads it
through _AudioControls to a new boundKeyLabel prop on
PttCapabilityBadge. When the prop is non-empty the badge renders
a second line below the existing row:
PTT: L2WindowsRawInput (windows-raw-input) [ⓘ] [Configure]
Key: Space
The label uses bodySmall + monospace + onSurfaceVariant to stay
visually subordinate to the capability descriptor. Two new l10n
entries (en + zh) cover the 'Key: {key}' string.
Privacy: the displayed label is the same platform-neutral
LogicalKeyboardKey.keyLabel string the dialog already shows
during capture and that already crosses the bridge as
PttBinding.platform_key. No raw OS key code is introduced
(DEC-027 / SDD-077 compliance preserved).
State scope: display-only cache that resets on app restart. The
bridge-side PttController (SDD-088) holds the authoritative
binding; this UI cache is purely for display continuity within
a single process.
Verified on Linux: flutter analyze clean, cargo test --workspace
55/0/3.
|
||
|
|
9831624079 |
feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)
The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:
* SDD-088 PttController — backend ownership + binding mutex +
capability watch lived split between AudioEngine and
ChanoraSession. Extracted into chanora_core::ptt::PttController.
AudioEngine now owns only the cpal streams and the missed-key-up
watchdog (SDD-092); the platform input backend, the active
PttBinding, and the capability watch::Sender live in the
controller. ChanoraSession::start_audio constructs the controller
against the engine's gate; disconnect/reconnect/restart paths
tear it down through stop().await before the engine.
* SDD-090 PttSanitizer — banned-field check was inlined as
PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
into a generic PttSanitizer<L> tracing_subscriber::Layer that
decorates an inner Layer (canonical pairing:
RedactingLogLayer::with_sanitizer). The inner layer keeps its
own structural ban check as defence-in-depth for bare-install
callers.
* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
into a public PttCapabilityBadge widget and added the
SDD-091-specified per-platform explanation sheet that opens on
the info-icon tap when the resolved capability is L0Focused.
New l10n strings (en + zh) cover the sheet copy.
Tests:
* 2 new unit tests for PttController (arm + descriptor watch)
* 1 new unit test for PttSanitizer (end-to-end through a real
tracing subscriber proving banned drop + safe forward)
cargo test --workspace: 55 passed / 0 failed / 3 ignored
cargo deny check: advisories ok, bans ok, licenses ok, sources ok
flutter analyze: no issues
tools/validate_docs.py: zero undefined refs, zero direct-layer
violations (pre-existing 35 old-package-name warning unchanged)
No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
|
||
|
|
03f5d6bca3 |
fix(p0): close three P0 coverage gaps after rc.5 audit
Audited every `Priority: P0` row in `docs/requirements/{sysrs,srs}.md`
against the live code. Three items needed work; this commit closes
all three.
Gap A — SysRS-262 + SysRS-282 (screen-reader semantics + accessible
labels for the PTT control)
-------------------------------------------------------------------
The Flutter PTT control is a custom `Listener` over a `Container`
— not a built-in `Button`, so the platform accessibility tree had
no idea it was an interactive control. Screen readers
(VoiceOver, TalkBack, NVDA, Orca) would have read the visible text
without announcing the control role or its toggled state.
Wrap the Listener in a `Semantics(button: true, toggled: _pressed,
label: …, hint: …, excludeSemantics: true)` so the platform
accessibility tree carries the right role, the current state
("Hold to talk" / "Transmitting"), and a usage hint. The
`excludeSemantics: true` argument suppresses the duplicate child
nodes the Container + Row + Icon + Text would otherwise generate
on top of our explicit label.
SysRS-263 (no colour-only state) is preserved: the visible label
and the mic icon already differentiate the two states without
relying on the colour transition.
New ARB key `pttHoldToTalkSemanticsHint` in `app_en.arb` and
`app_zh.arb`.
Gap B — SRS-198 (macOS async permission re-check)
-------------------------------------------------
The macOS backend queried `query_permission()` once at
construction and never re-checked. That violates SRS-198's
"upgrade to the appropriate Global level only after the user
grants the required permission" — once Chanora is running, a
runtime grant must lift the descriptor from `L0Focused` to a
Global level without an app restart.
Substantive rewrite of `crates/chanora_audio/src/ptt_backends/macos.rs`:
* `permission: PermissionState` becomes `permission: Arc<AtomicU8>`,
enabling cross-thread updates without a Mutex.
`PermissionState::{to_u8, from_u8}` carry the encoding.
* The backend owns a `tokio::sync::watch::Sender<PttBackendDescriptor>`
and overrides `DesktopPttBackend::descriptor_watch()` to hand
out subscribers; `chanora_core::ChanoraSession::start_audio`
already forwards transitions to `SessionEvent::PttCapability`.
* `start()` spawns a `chanora-perm-watch` OS thread that polls
`query_permission()` every 1.5 s and republishes the
descriptor on every transition. Polling rather than KVO /
notifications because Input-Monitoring has no public
change-notification API on macOS; 1.5 s is sufficient for a
user grant + return-to-Chanora cycle.
* `rebind()` also republishes the descriptor so a
`keyboard → mouse-side-button` change updates the badge.
* Six new unit tests on the platform-independent
`build_descriptor` and the atomic encoding contract. They
only compile under `target_os = "macos"` (consistent with
the rest of the module), so the Linux dev-host workspace
test count is unchanged.
`query_permission()` itself still returns `Undetermined` until
the IOKit live link lands in the macOS platform-verification
commit; the re-query loop will engage the upgrade path
automatically the moment that function returns real values.
Gap C — SRS-200 (Linux mouse-side-button portal-dependence)
-----------------------------------------------------------
`desktop-ptt-architecture.md` §5.3 already described the
heuristic classifier. Added one explicit sentence stating that
Linux mouse-side-button support is *portal-dependent*: Chanora
never claims a fixed Mouse4/Mouse5 binding on Linux; the portal
decides what inputs it accepts in the current session, and the
classifier degrades to `keyboard` whenever the portal's
description does not contain "mouse". This matches the SRS-200
text verbatim and removes the ambiguity over what "Linux
support follows the portal" means in practice.
Verification
------------
* `cargo test --workspace` (with `CHANORA_DISABLE_KEYRING=1`):
all 67 Linux-side tests green (unchanged). The new macOS
unit tests count under `target_os = "macos"` only — they
will report once the macOS reference host runs `cargo test`.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `flutter analyze`: clean (no new accessibility warnings).
* Linux release bundle builds clean.
P0 audit summary
----------------
After this commit every Priority: P0 row in `sysrs.md` and
`srs.md` has a concrete implementation. The remaining open items
are all live verification, not code:
* Per-platform live PTT traces on Windows / macOS reference
hosts (RR-PTT-001..003, RR-PTT-008) — hosts unavailable
locally; queued for platform owners.
* Linux GNOME-Wayland live trace (RR-PTT-004) — implemented
in rc.5; awaiting live host trace.
* Linux non-tested compositor fallback trace (RR-PTT-005) —
Open.
* Diagnostic-export key-leak inspection (RR-PTT-006) — Open
but trivially testable on any host with PTT bound.
* DEC-012 legal review — engineering hand-off complete since
rc.2.
|