190 Commits
Author SHA1 Message Date
EdisonJwa 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).
2026-05-16 15:50:27 +08:00
EdisonJwa 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).
2026-05-16 15:41:08 +08:00
EdisonJwa 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.
2026-05-16 15:15:38 +08:00
EdisonJwa f5d3810f5f feat(ios,macos): Apple privacy manifest (PrivacyInfo.xcprivacy)
Apple has enforced a `PrivacyInfo.xcprivacy` privacy manifest at App
Store submission since May 2024 for iOS / iPadOS / visionOS /
watchOS, and rolled the requirement out to macOS in late 2024.
Without the file, App Store Connect rejects archive uploads with
"missing required privacy manifest". This commit adds the manifest
for both iOS and macOS Runner targets.

apps/chanora_flutter/ios/Runner/PrivacyInfo.xcprivacy
apps/chanora_flutter/macos/Runner/PrivacyInfo.xcprivacy
  Identical content. Declarations:

  NSPrivacyCollectedDataTypes:
    NSPrivacyCollectedDataTypeAudioData
      Microphone audio transmitted to the user's chosen voice
      server while connected and unmuted. Not linked to user
      identity (no Apple ID / IDFA tied), not used for tracking.
      Purpose: AppFunctionality (communications).

  NSPrivacyTracking: false
  NSPrivacyTrackingDomains: []
    Chanora performs no cross-app / cross-website tracking.

  NSPrivacyAccessedAPITypes:
    FileTimestamp (C617.1)
      tokio + rusqlite file I/O for identity.tskey, chanora.db,
      audio_meta.json, chanora.log inside the app container.
    UserDefaults (CA92.1)
      Indirect via path_provider Flutter plugin querying for
      Application Support / Documents directories.
    SystemBootTime (35F9.1)
      tracing-subscriber timestamps log records relative to boot.
    DiskSpace (85F4.1)
      rusqlite checks before sqlite page writes.

  All four "required reason" API categories use Apple's published
  allow-list reason codes; no fingerprinting / analytics usage.

apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj
  Added PrivacyInfo.xcprivacy to the Runner group and to the
  Runner target's "Copy Bundle Resources" build phase via the
  xcodeproj Ruby gem (via a one-shot script). With this, the file
  is placed at Runner.app/PrivacyInfo.xcprivacy where Apple's
  validator looks for it — `find Runner.app -name
  PrivacyInfo.xcprivacy` shows our manifest at the bundle root
  alongside Flutter's and connectivity_plus's.

Verified on the M1 Mac (coder@100.118.130.73):
  flutter build ios --release --no-codesign                4.0 s
    -> Runner.app/PrivacyInfo.xcprivacy present
  flutter build ipa --release --no-codesign                28.4 s
    -> Runner.xcarchive built (171.4 MB)
    -> archive's Runner.app/PrivacyInfo.xcprivacy present
    -> archive's Runner.app/Frameworks/chanora_bridge.framework
       built fresh via the chanora_bridge.podspec prepare_command
       under xcodebuild's sandbox (no PATH / env weirdness).

P1 follow-ups noted by xcodebuild's validator (not blockers for
this commit but for App Store submission):
  * Real app icon (currently default placeholder)
  * Real launch image (currently default placeholder)
  * Paid Apple Developer Program account, registered App ID, and
    Distribution provisioning profile (Personal Team sideloads
    still work as today).
2026-05-16 16:03:39 +09:00
EdisonJwa 1f729284b3 feat(flutter,ios): vendor chanora_bridge as a CocoaPods framework
iOS rejects loose .dylib loads (`dlopen` of any path outside the
app bundle is sandboxed), so a Flutter-Rust bridge has to ship
inside the .app as an Embed-and-Sign framework that
flutter_rust_bridge's runtime loader can dlopen via its default
`chanora_bridge.framework/chanora_bridge` lookup path.

This commit wires the bridge into the iOS build via a CocoaPods
podspec. Same role Cargokit plays for other Flutter+Rust setups,
done by hand against this repo's layout to avoid the Cargokit
vendoring footprint that was previously dropped.

  apps/chanora_flutter/ios/chanora_bridge.podspec
    New file. `prepare_command` invokes
    `cargo build --release --target aarch64-apple-ios -p chanora_bridge`
    with IPHONEOS_DEPLOYMENT_TARGET=13.0 +
    CMAKE_POLICY_VERSION_MINIMUM=3.5 (satisfies audiopus_sys's
    cmake invocation on modern CMake 4.x), then wraps the
    produced libchanora_bridge.dylib into
    chanora_bridge.framework with an Info.plist that declares
    iPhoneOS / MinimumOSVersion=13.0, and rewrites LC_ID_DYLIB
    to @rpath/chanora_bridge.framework/chanora_bridge.
    `vendored_frameworks` exposes the result to CocoaPods, which
    integrates it into Runner.xcodeproj with Embed & Sign
    automatically. No Xcode UI edits required.

  apps/chanora_flutter/ios/Podfile
    Add `pod 'chanora_bridge', :path => '.'` to the Runner
    target.

  apps/chanora_flutter/ios/Podfile.lock
    Generated by `pod install` after the pod was added. Pins
    chanora_bridge 1.0.0 + checksum so iOS builds on other
    developer machines pull the same framework version.

  .gitignore
    Add `/apps/chanora_flutter/ios/Frameworks/` so the
    ~13 MB built framework (regenerated on every pod install) is
    not committed.

Verified end-to-end on the M1 Mac (coder@100.118.130.73):
  pod install                                              ok
    chanora_bridge.framework generated at
    apps/chanora_flutter/ios/Frameworks/chanora_bridge.framework
    Install name: @rpath/chanora_bridge.framework/chanora_bridge
  flutter build ios --release --no-codesign                12.7 s
    -> Runner.app 29.9 MB (was 16.9 MB without the bridge)
    -> Runner.app/Frameworks/chanora_bridge.framework present
       alongside Flutter.framework, App.framework,
       connectivity_plus.framework, objective_c.framework.

DEC-025: iOS / iPad officially in scope for P0.

Notes for owners installing on physical iOS devices:
  The Personal Apple Team in Xcode requires a unique
  PRODUCT_BUNDLE_IDENTIFIER (the default 'app.chanora.chanoraFlutter'
  may already be claimed in the App Store registry). Set this
  locally in Xcode -> Runner target -> Signing & Capabilities ->
  Bundle Identifier (e.g. yourname.chanora.chanoraFlutter); do
  NOT commit that change back since it is user-specific.
2026-05-16 15:57:36 +09:00
EdisonJwa f0ddb160a0 fix(protocol,audio,ios): native-tls instead of rustls+aws-lc-rs
Building the bridge for `aarch64-apple-ios` failed in two ways with
the previous TLS stack:

  1. `aws-lc-sys` (transitive: rustls -> aws-lc-rs -> aws-lc-sys)
     does not cross-compile cleanly to iOS — the build produced
     undefined symbols for architecture arm64 (mldsa44, ec_GFp_mont,
     etc).
  2. `audiopus_sys` linked against the wrong iOS runtime version,
     missing `___chkstk_darwin`.

Following the rustls-platform-verifier docs and the standard Rust+
iOS+TLS pattern used by 1Password / Signal / rustup / Bitwarden,
this commit swaps the TLS provider to **native-tls** so each
platform picks its own:

  * macOS + iOS  -> Security.framework (no external C deps)
  * Windows      -> SChannel
  * Linux/BSD    -> system OpenSSL

Changes:

crates/chanora_protocol/Cargo.toml
crates/chanora_audio/Cargo.toml
  * Drop `default-tls` from tsclientlib's features. The remaining
    `audio` feature is what we actually use; default-tls was a
    reqwest convenience that picked rustls+aws-lc-rs.
  * Add a direct `reqwest` dep with `default-features = false,
    features = ["charset", "http2", "native-tls"]`. Cargo's
    workspace feature unification carries this through the
    transitive `tsclientlib -> reqwest` chain.

apps/chanora_flutter/ios/Podfile
  * Uncomment `platform :ios, '13.0'` so CocoaPods stops emitting
    the implicit-platform warning and Xcode's iOS deployment-
    target check is honored.

apps/chanora_flutter/ios/Podfile.lock
  * Generated by `pod install` after the platform pin. Committed so
    iOS builds on other developer machines pull the exact same Pod
    versions.

apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
apps/chanora_flutter/ios/Runner.xcworkspace/contents.xcworkspacedata
  * CocoaPods auto-integration: adds Pods_Runner.framework +
    Pods_RunnerTests.framework references and the Pods xcconfig
    file references. Standard `pod install` output; reviewing the
    diff shows only Pod-bookkeeping additions, no signing or
    target-config drift.

Verified end-to-end on the M1 Mac (coder@100.118.130.73):
  cargo build --release -p chanora_bridge                29.09 s
  cargo build --release --target aarch64-apple-ios       24.32 s
    (with IPHONEOS_DEPLOYMENT_TARGET=13.0 and
     CMAKE_POLICY_VERSION_MINIMUM=3.5 in the env to satisfy the
     audiopus_sys cmake invocation; documented as a P1 build-glue
     follow-up.)
  flutter build ios --release --no-codesign              ok
    Built build/ios/iphoneos/Runner.app (16.9 MB)
  Xcode GUI build of Runner.xcworkspace                  ok
    (after the user opened Runner.xcworkspace, NOT
     Runner.xcodeproj, and Clean Build Folder.)

Tests on macOS unchanged: chanora_audio 34 / 0 / 0.

DEC-025: iOS + macOS officially in scope for P0.
2026-05-16 15:32:08 +09:00
EdisonJwa f3320715ea feat(audio,ios): AVAudioSession PlayAndRecord+voiceChat in AppDelegate
iOS AVAudioSession must be configured BEFORE Flutter starts its
audio pipeline; the canonical place is application(_:didFinishLaunching\
WithOptions:) in AppDelegate.swift. This commit:

apps/chanora_flutter/ios/Runner/AppDelegate.swift:
  * import AVFoundation
  * In application(_:didFinishLaunchingWithOptions:), call
    AVAudioSession.sharedInstance().setCategory(.playAndRecord,
      mode: .voiceChat,
      options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
    followed by setActive(true). Failures are NSLogged but do not
    block app launch — cpal's CoreAudio backend will still come up
    against the default iOS routing.

  This shape:
   * routes the receiver/speaker like a phone call (.playAndRecord +
     .voiceChat),
   * engages on-device AEC / NS where supported,
   * defaults to speaker so users don't have to hold the phone to
     their ear,
   * permits Bluetooth headsets (AirPods et al. just work).

crates/chanora_audio/src/engine.rs:
  * Replace the iOS engine-start placeholder log line ('binding
    pending — Chanora iOS audio is documented-only for Beta') with
    an honest acknowledgment that the AVAudioSession configuration
    lives Swift-side. The Rust engine acknowledges the request, then
    cpal opens its CoreAudio streams against the session.

iOS-only Rust code is #[cfg(target_os = "ios")]-gated so this commit
is no-op on every other platform.

SRS-197: iOS/macOS audio routing contract. DEC-025: iOS officially
in scope for P0 (Focused PTT only — Apple's sandbox model has no
global PTT analogue).
2026-05-16 13:50:43 +08:00
EdisonJwa e3d7017dd9 feat(macos,ios): entitlements + permission strings + bundle-glue script
macOS:
  * Runner/DebugProfile.entitlements + Release.entitlements: add
    com.apple.security.network.client (outbound TS3 server connect)
    and com.apple.security.device.audio-input (microphone capture).
    Debug keeps com.apple.security.network.server + cs.allow-jit
    (Flutter hot-reload needs both); Release drops them.
  * Runner/Info.plist: add NSMicrophoneUsageDescription and
    NSInputMonitoringUsageDescription so the macOS system prompts
    show a sensible explanation when Chanora first needs mic or
    Input Monitoring access. Input Monitoring is required by
    CGEventTapCreate (SDD-085).
  * Runner.xcodeproj/project.pbxproj: switch Debug/Release/Profile
    code-signing from Automatic + Apple Development to Manual +
    "Sign to Run Locally" (CODE_SIGN_IDENTITY = -). This lets
    `flutter build macos --release` work over SSH where the login
    keychain is locked. The owner re-enables the personal team
    locally in Xcode for physical-device iOS testing later.

iOS:
  * Runner/Info.plist: add NSMicrophoneUsageDescription and the
    UIBackgroundModes = ['audio'] entry so voice traffic continues
    when the app is backgrounded (TS3 servers drop clients on idle
    audio streams).

tools/macos-postbuild.sh: new script. flutter build macos --release
emits build/macos/Build/Products/Release/chanora_flutter.app but
does NOT bundle libchanora_bridge.dylib. FRB on macOS dlopen()s the
bridge as chanora_bridge.framework/chanora_bridge, not a plain
dylib. This script:

  1. Wraps target/release/libchanora_bridge.dylib in a proper
     chanora_bridge.framework (Versions/A layout, Info.plist,
     Resources, symlinks).
  2. Rewrites LC_ID_DYLIB to
     @rpath/chanora_bridge.framework/chanora_bridge.
  3. Ad-hoc codesigns the framework and the .app bundle.
  4. Verifies with codesign --verify --deep --strict.

macOS analogue of buildit.cmd on Windows. Auto-integration into
Xcode build phases via cargokit / corrosion is a P1 carryover.

Verified end-to-end on the M1 Mac:
  cargo build --release -p chanora_bridge          11.76 s
  flutter build macos --release                    ok (59.2 MB)
  tools/macos-postbuild.sh Release                 ok
  chanora_flutter.app launch via SSH               bridge initialised,
                                                   identity + bookmark
                                                   store initialised
                                                   (~5 s smoke).
  ~/Library/Logs/app.chanora.chanora_flutter/chanora.log captures
  the boot sequence cleanly.

DEC-025 reference: macOS desktop is officially in scope.
2026-05-16 14:26:13 +09:00
EdisonJwa 4d57d189c9 feat(flutter,macos,ios): scaffold platform Xcode projects via flutter create
Ran `flutter create --platforms=macos,ios --project-name=chanora_flutter
--org=app.chanora .` on the M1 Mac to generate the standard Flutter
platform-specific scaffolding (Runner.xcodeproj, Podfile, AppDelegate,
entitlements, etc.) for both macOS and iOS.

The cross-platform Dart source (lib/) and Rust workspace (crates/,
core/) carry the actual application logic; these scaffolds are
required only so flutter build macos / ios can resolve their Xcode
projects. No application code added.

macOS smoke-launch from the M1 Mac verified the bridge dylib load
path: after manually wrapping libchanora_bridge.dylib into a proper
chanora_bridge.framework bundle (FRB on macOS expects a framework,
not a plain dylib) and codesigning ad-hoc, the runner starts cleanly
through 'bridge initialised', 'identity store initialised', 'bookmark
store initialised' just like the Linux runner.

Following commits will:
  * Wire the framework-bundling step into build glue (currently manual
    install_name_tool + codesign).
  * Replace the macOS PTT backend stub (crates/chanora_audio/src/
    ptt_backends/macos.rs) with live IOHIDCheckAccess +
    CGEventTapCreate so the descriptor advertises real L2/L3 capability
    on a permission-granted box (SDD-085).
  * Add the iOS AVAudioSession PlayAndRecord+voiceChat wiring.
  * Add docs/verification/macos-p0-acceptance.md and ios-p0-acceptance.md.
2026-05-16 14:07:33 +09:00
EdisonJwa 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).
2026-05-16 12:15:13 +08:00
EdisonJwa 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.
2026-05-16 03:27:25 +08:00
EdisonJwa 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).
2026-05-16 01:52:47 +08:00
EdisonJwa 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.
2026-05-16 01:07:00 +08:00
EdisonJwa 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).
2026-05-16 00:57:10 +08:00
EdisonJwa 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).
2026-05-16 00:31:57 +08:00
EdisonJwa 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).
2026-05-16 00:18:18 +08:00
EdisonJwa 33d80894d0 feat(ptt,audio): wire PTT key edges through ReleaseTailTimer (SDD-096)
Previously the PttController handed its real AudioTransmitGate to
the platform backend and the backend wrote transmit_active directly
on every key edge — bypassing the 200 ms release tail and the
TransmitMode selector entirely. The tail timer was constructed and
exposed on ChanoraSession but never received any input, so SDD-096
and SRS-206 were spec-only.

Wire it: PttController now owns a synthetic 'press-edge gate' which
it hands to the backend in place of the real one. An internal
edge-watcher task subscribes to that press-gate, translating
true/false transitions into ReleaseTailTimer.key_down/key_up calls.
The release-tail timer feeds the selector's ptt_held input; the
selector recomputes transmit_active honouring mode, in_channel,
and hard_mute, and writes the real gate. Single owner of
transmit_active is preserved (SAD-083 invariant).

PttController::new now takes Arc<ReleaseTailTimer> instead of
AudioTransmitGate; ChanoraSession threads its session-scoped timer
through both the start_audio path and the supervisor reconnect
path. The legacy bridge set_ptt call (still used by the in-focus
Listener fallback and the e2e test) is rerouted through the timer
so the same tail and mute semantics apply uniformly.

ReleaseTailTimer gains force_release() — cancels any pending task
AND clears the selector's ptt_held. PttController::stop uses it so
shutdown can't leave transmit_active stuck at true.

Tests
- press_edge_drives_selector_through_release_tail: backend press
  edge → real gate follows, key_up → tail keeps gate true for tail
  window then clears.
- stop_clears_press_and_cancels_tail: stop() drops transmit even
  with a tail in flight.
- e2e test now sets release_tail_ms=0 + waits one tick so the
  pttActive=false assertion isn't racing the default 200 ms tail.

cargo test --workspace --lib: 74 passed / 0 failed / 1 ignored
(+2 new tests vs. the previous 72).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 00:05:54 +08:00
EdisonJwa 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 6a41a0b.

Also removed the dead  legacy widget class (lines
1001-1181) — it had no callers since the VoiceBar refactor in
ba444d9 but was still cluttering the file and even held a stale
reference to PttCapabilityBadge's old constructor signature.

The bound-key string is no longer duplicated either: the Voice Bar's
PTT-only secondary line ('PTT: Space   ·   Release tail: 200ms')
remains the only place that shows the bound key, since it's also
the only PTT-mode-gated surface.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-15 23:52:23 +08:00
EdisonJwa 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.
2026-05-15 23:45:20 +08:00
EdisonJwa 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).
2026-05-15 23:34:53 +08:00
EdisonJwa 7f4874f4c0 chore(bridge): regenerate FRB bindings for log_file_path_str 2026-05-15 23:25:50 +08:00
EdisonJwa 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 dfa84ee.

Rust side
- chanora_audio::TransmitMode enum (Ptt/Continuous/VoiceActivity) with
  serde-friendly u8 repr (SDD-095).
- chanora_audio::TransmitModeSelector: lock-free Atomic-backed selector
  that is the sole writer of transmit_active (per SAD-083), applying
  hard_mute as a final clamp. VoiceActivity falls through to Continuous
  for v1 (DEC-030 placeholder).
- chanora_audio::ReleaseTailTimer: tokio-task-owning struct driving the
  selector's ptt_held input; default 200 ms tail, configurable 0–500 ms
  with AtomicU32 hot read; pending JoinHandle held in a std::sync::Mutex
  touched only on PTT edge transitions (SDD-096).
- chanora_storage: AudioMeta persisted as audio_meta.json next to
  identity.dek; get/set_transmit_mode + get/set_release_tail_ms with
  0..=500 clamp on write.
- chanora_core::ChanoraSession: voice_join(channel, password) and
  voice_leave() are the new lifecycle entry points; ensure_audio_running
  and shutdown_audio_if_idle are private helpers around the existing
  Option<AudioEngine> field. SessionEvent::VoiceState carries the
  in_channel / transmit_mode / mute / release_tail_ms tuple. Selector
  state survives reconnect; supervisor rewires it to each fresh engine
  gate.
- chanora_bridge: drop start_audio; add voice_join, voice_leave,
  set/get_transmit_mode, set/get_release_tail_ms, set_hard_mute.
  BridgeEvent::VoiceState mirrors the core event. AudioStarted/Stopped
  kept for backwards compat but Flutter ignores them in the new UI.

Flutter side
- New apps/chanora_flutter/lib/widgets/voice_bar.dart replaces the
  legacy _AudioControls widget. Renders channel pill, mode badge,
  mute toggle, level meter, PttCapabilityBadge, leave button. No
  manual Start affordance anywhere.
- New apps/chanora_flutter/lib/widgets/voice_settings.dart dialog with
  TransmitMode radio group (VoiceActivity disabled with 'Coming soon'
  trailing label per DEC-030), bind-key button, release-tail slider
  0–500 ms step 25.
- main.dart: state fields _inChannel, _transmitMode, _hardMute,
  _releaseTailMs driven by BridgeEvent_VoiceState. Channel-tap now
  calls voiceJoin instead of moveToChannel. Removed _onStartAudio,
  _audioStarted-gated branch, and the FilledButton.
- l10n: 11 new strings in app_en.arb + app_zh.arb.

Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored
  (chanora_audio: +12 new tests for TransmitMode/Selector/ReleaseTail;
  chanora_storage: +2 new tests for audio_meta round-trip).
- flutter analyze: 0 errors, 0 warnings; 6 infos are the Flutter 3.32
  Radio.groupValue deprecation (pre-existing API usage).
- FRB Dart/Rust bindings regenerated via flutter_rust_bridge_codegen.

Follow-up (intentionally deferred)
- PttController and per-platform PTT backends still drive AudioTransmitGate
  directly via the legacy set_ptt path; routing those key edges through
  ChanoraSession::release_tail_timer().{key_down,key_up} so the tail
  applies to native PTT input is a contained wiring change in a follow-up.
- Real audio-level RMS in BridgeAudioStats (current meter is binary).
- VoiceActivity backend (DEC-030).
2026-05-15 23:05:37 +08:00
EdisonJwa 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.
2026-05-15 20:38:51 +08:00
EdisonJwa 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.
2026-05-15 20:35:42 +08:00
EdisonJwa 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.
2026-05-15 17:45:03 +08:00
EdisonJwa 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.
2026-05-15 16:50:48 +08:00
EdisonJwa 82d012a46b feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025)
Promotes the Linux backend from probe-only to a live
`org.freedesktop.portal.GlobalShortcuts` session, closing the
gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I
flagged on the review pass are addressed:

  * Stop now closes the portal session through the dedicated
    `org.freedesktop.portal.Session` interface (not the
    request-cancel `Request` interface — that would only abort a
    pending Request, not release the bound shortcuts).
  * Ten new unit tests cover `classify_shortcuts_value`,
    `publish_bound`, `publish_l0`, and the `SHORTCUT_ID` stability
    contract using synthesised `OwnedValue` payloads. Live D-Bus
    coverage stays in the `linux_portal_smoke` ignored
    integration test (RR-PTT-004).

Live session lifecycle (gen2 Q5b — lazy, single backend instance):

  1. `start(gate, binding)` spawns one `tokio::spawn` worker that
     owns an async `zbus::Connection` (sharing the bridge's
     tokio runtime per Q4a).
  2. `CreateSession` with fresh random `handle_token` /
     `session_handle_token` tokens. The worker awaits the portal
     `Response` signal via a `RequestProxy` subscription and
     extracts `session_handle` from the results dict.
  3. `BindShortcuts(session_handle, [("chanora-ptt", { description
     = "Chanora push-to-talk" })], "", {})`. The portal opens its
     own system-managed dialog asking the user to choose a key
     — Chanora itself never reads raw key events. The audio
     engine continues at `L0Focused` while the dialog is open;
     the descriptor watch publishes the transition once the
     portal returns.
  4. On `response_code == 0`: classify the `trigger_description`
     substring (heuristic: contains "mouse" -> MouseSideButton,
     else Keyboard), publish `L2GlobalHoldToTalk` (or `L3` for
     mouse) through the watch sender. The raw trigger_description
     string is never logged (DEC-027 / SRS-202).
  5. On `response_code == 1` (cancelled) or `>= 2` (failure):
     publish `L0Focused` through the watch sender. The user can
     retry via the UI "Configure" button (gen2 Q6a).
  6. The worker enters a `tokio::select!` loop multiplexing the
     `cmd_rx` channel (Rebind / Stop) and the `Activated` /
     `Deactivated` signals. Matching signals scoped to this
     session handle and `chanora-ptt` shortcut id drive
     `gate.set(true/false)`.
  7. `Rebind` re-runs `BindShortcuts` on the same session.
  8. `Stop` calls `org.freedesktop.portal.Session.Close()` on
     the session-handle object path, clears the gate, exits.

UX (gen2 Q3a): when `_pttBackendId == 'gnome-wayland-portal'`,
the Flutter "Configure" button skips the in-app
`_PttBindingCaptureDialog` and shows a SnackBar telling the user
their desktop environment will open its own shortcut dialog.
The button delegates to `setPttBinding(keyboard, "portal")`
which nudges the backend; the portal handles the rest. New ARB
key `pttConfigurePortalRedirect` in en + zh-Hans.

Trait surface (cross-cutting):

  * `DesktopPttBackend::descriptor_watch()` is a new trait method
    with a default impl returning a never-firing receiver.
    Backends with async capability transitions (only the Linux
    portal backend today) override it to return the live watch
    sender's receiver.
  * `chanora_core::ChanoraSession::start_audio` subscribes to the
    active backend's `descriptor_watch()` and spawns a forwarder
    task that re-emits `SessionEvent::PttCapability` on every
    transition. The initial value is emitted synchronously.

`Cargo.toml` (Linux-only):

  * `futures-util` (std features, no executor) for stream
    consumption on the portal signal subscriptions.
  * `rand 0.8` for fresh per-process portal tokens.
  * `zbus` continues at v5 with the `tokio` + `blocking-api`
    features.

Tests
-----

  * `chanora_audio` rises from 8 to 18 unit tests. New
    coverage on the Linux module:
      - `classify_returns_none_when_shortcut_id_missing`
      - `classify_returns_keyboard_for_typical_trigger_description`
      - `classify_returns_keyboard_when_trigger_description_missing`
      - `classify_detects_mouse_substring`
      - `classify_is_case_insensitive_on_mouse_substring`
      - `publish_bound_keyboard_publishes_L2_with_keyboard_class`
      - `publish_bound_mouse_publishes_L3`
      - `publish_bound_none_publishes_L2_keyboard_default`
      - `publish_l0_clears_descriptor`
      - `shortcut_id_is_stable`
  * Workspace total: 67 unit + integration tests, all green with
    `CHANORA_DISABLE_KEYRING=1` (was 57 at v1.0.0-rc.4).
  * New `crates/chanora_audio/tests/linux_portal_smoke.rs`
    ignored integration test (RR-PTT-004 evidence path). Run on
    a GNOME-on-Wayland host with
    `cargo test -p chanora_audio --test linux_portal_smoke -- --ignored --nocapture`.

Documentation
-------------

  * `docs/architecture/desktop-ptt-architecture.md` §5.3 rewritten
    to describe the realised lifecycle; v0.9.4 change-history
    entry added.
  * `docs/governance/product-decision-register.md` v0.9.10
    change-history entry recording the code-side promotion. No
    decision rows mutate.
  * `docs/release/release-readiness-go-nogo-record.md` RR-PTT-004
    flipped from `Open` to `Implemented (live trace pending)`;
    v0.9.5 change-history entry.

Verification
------------

  * `cargo test --workspace`: 67/67 green.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: zero new warnings.
  * `tools/dump_flutter_licenses.sh`: 94 packages, 0 without
    LICENSE.
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` +
    `flutter build linux --release`: clean Linux x86_64 bundle.
  * Live portal trace (RR-PTT-004) — **not run**. The dev shell
    is a TTY without a Wayland session. The user will run the
    ignored smoke test from inside a GNOME-on-Wayland session
    when available.

No Windows / macOS / iOS live verification in this commit (hosts
unavailable). The Windows + macOS backend scaffolds remain in
place reporting their target capability honestly; live OS-call
wiring is queued for their respective platform owners'
reference hosts per `docs/governance/staged-release-plan.md`.
2026-05-15 16:43:45 +08:00
EdisonJwa 5199e3d005 feat(ptt): full desktop backend ladder + missed-key-up watchdog (gen2 v0.9.3 follow-up)
Lands SDD-081..088 + SDD-092 implementations on top of v1.0.0-rc.3.
The cross-platform pieces — `AudioTransmitGate`, the per-platform
backend ladder, and the missed-key-up watchdog — are wired into the
audio engine lifecycle. Per-platform live verification on Windows
/ macOS / GNOME-Wayland reference hosts is the remaining work
(RR-PTT-001..006/008 in `release-readiness-go-nogo-record.md`).

`chanora_audio::ptt`
--------------------

  * `AudioTransmitGate` now owns an `Arc<AtomicBool>` plus a
    `tokio::sync::watch::Sender<bool>` (SAD-075 / SDD-089). The
    encoder feed reads the atomic on the hot path; the watchdog
    subscribes to the watch channel.
  * `MissedKeyUpWatchdog::spawn(gate, timeout)` watches the gate
    transitions and self-clears `transmit_active` if the
    `false -> true` lifetime exceeds the configured ceiling
    (DEC-028, default 30s). Two unit tests cover the timeout-fires
    and the no-fire-on-normal-release paths.

`chanora_audio::ptt_backends`
-----------------------------

  * `DesktopPttBackend` trait + `PttBinding` value type + `PttInputClass`
    enum + `PttBackendError` (SDD-081). `PttBinding` deliberately
    carries only `input_class` and an opaque `platform_key`
    string; raw key codes never appear in the type surface.
  * `select()` factory (SAD-071): runtime ladder evaluation per
    OS. Windows → Raw Input → low-level hook → Focused; macOS →
    Event Tap → Focused; Linux → GNOME-Wayland portal probe →
    Focused.
  * `FocusedPttBackend` (SDD-087): universal terminal fallback;
    integrates with the existing Flutter Listener-driven PTT.
  * `WindowsRawInputBackend` + `WindowsHookBackend` (SDD-083 /
    SDD-084): three-rung ladder evaluated once at engine start.
    Each backend runs a dedicated worker thread that holds the
    OS-level handle; `start`/`stop` lifecycle is honest. Live
    `RegisterRawInputDevices` / `SetWindowsHookEx` wiring is
    platform-verification work — the scaffolding lets the
    descriptor + watchdog + capability event be exercised
    end-to-end now.
  * `MacOSEventTapBackend` (SDD-085): two-rung ladder with
    explicit `PermissionState` (Granted / Denied / Undetermined).
    `Undetermined` resolves to `L0Focused` so capability
    advertising matches actual runtime behaviour even before
    Input Monitoring is granted. Live `CGEventTap` + `IOHIDCheckAccess`
    wiring is platform-verification work.
  * `LinuxGnomeWaylandBackend` (SDD-086): probes GNOME-on-Wayland
    via `XDG_SESSION_TYPE` + `XDG_CURRENT_DESKTOP`, then verifies
    the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface
    is reachable by reading the `version` property over a
    blocking zbus session. Reports `gnome-wayland-portal` /
    `L2GlobalHoldToTalk`. Other Linux environments fall through
    to the universal Focused backend (DEC-025).

`chanora_audio::engine`
-----------------------

  * Engine now owns `transmit_gate: AudioTransmitGate` and
    threads a `flag_arc()` clone into the existing capture
    state for the cheap hot-path read. `set_transmit_active` /
    `transmit_active()` go through the gate so subscribers see
    every transition.
  * `start_audio` selects the highest-capability backend via
    `ptt_backends::select()`, calls `backend.start(gate, none())`,
    and spawns the watchdog. Both are released in `stop()` and
    on Drop.
  * New `engine.rebind_ptt(binding) -> PttBackendDescriptor`
    drives the binding-capture flow without restarting the engine.
  * New `engine.ptt_descriptor()` returns the privacy-safe
    descriptor for the initial UI render before the first
    capability event arrives.

`chanora_core`
--------------

  * Re-exports `PttBinding` + `PttInputClass`.
  * New `ChanoraSession::set_ptt_binding(binding)` — calls
    `audio.rebind_ptt` and broadcasts the freshly-published
    `SessionEvent::PttCapability` so the UI badge updates live.
  * New `ChanoraSession::ptt_descriptor()` for the initial render.

`chanora_bridge`
----------------

  * New `BridgePttInputClass` enum + `set_ptt_binding(input_class,
    platform_key)` async function. The `platform_key` string is
    opaque to the bridge and never logged.
  * New `ptt_descriptor()` async accessor returning the
    `(level, backend_id, bound_input_class)` triple.

Flutter
-------

  * `_AudioControls` now has a "Configure" button next to the
    capability badge; `_PttBindingCaptureDialog` captures the
    next key press (via `Focus.onKeyEvent`) or mouse side button
    (via `Listener.onPointerDown` filtered to button bitmasks
    `0x08` / `0x10`). The captured value is the platform-neutral
    `LogicalKeyboardKey.keyLabel` or `mouse-side-button:{button}`.
  * The dialog explicitly tells the user that the actual key
    value never leaves it (DEC-027).
  * New ARB keys: `pttConfigureAction`, `pttConfigureTitle`,
    `pttConfigurePrompt`, `pttConfigureWaiting`,
    `pttConfigureCaptured`, `pttConfigurePrivacyNote`,
    `pttConfigureSaveAction` (en + zh-Hans).

Dependencies
------------

  * `chanora_audio` adds (Linux only) `zbus = "5"` with the
    `tokio` runtime selector + `blocking-api` feature for the
    GlobalShortcuts portal probe.
  * `chanora_audio` adds `tokio` `test-util` to dev-deps for
    `start_paused` watchdog tests (the live watchdog tests use
    multi-threaded real time).

Verification
------------

  * `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`:
    57 tests green (was 53). chanora_audio rises from 4 to 8.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: regenerates
    `docs/security/license-inventory.{md,html}`. The crate count
    rises from 364 to 383 with the addition of the zbus tree.
  * `tools/dump_flutter_licenses.sh`: 94 packages, zero without
    LICENSE (unchanged).
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` + `flutter build
    linux --release`: clean Linux x86_64 bundle.

Documentation
-------------

  * `docs/release/release-readiness-go-nogo-record.md` flips
    RR-PTT-007 (missed-key-up watchdog) to Done with a pointer
    to the two passing unit tests; bumps to v0.9.4. Live
    per-platform traces (RR-PTT-001..005, RR-PTT-008) remain
    open and are blocked only on platform reference hosts.

Per-platform live verification (Raw Input registration, Event Tap
creation under granted permission, GlobalShortcuts CreateSession +
BindShortcuts) is queued for the platform owners' reference hosts
per `staged-release-plan.md`.
2026-05-15 15:38:42 +08:00
EdisonJwa 7b21916049 feat(ptt): code-side initial split — transmit_active / capability badge / sanitizer
Implements the gen2 v0.9.3 doc baseline's first slice of code work:

  * SRS-201: split the audio engine's `ptt` AtomicBool into the
    authoritative `transmit_active` flag. The legacy `set_ptt` /
    `ptt` accessors are retained as `#[doc(hidden)]` thin wrappers
    so the existing bridge command and the existing Flutter
    hold-to-talk UI keep compiling.
  * SAD-075 / SDD-089 acknowledged at the type level: only
    `AudioEngine::set_transmit_active` (or its legacy alias)
    mutates the flag; the encoder feed reads it once per outbound
    frame and never writes.
  * SDD-082: new `chanora_audio::ptt` module ships the
    `PttCapabilityLevel` enum (`L0Focused`, `L1GlobalShortcut`,
    `L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`,
    `L4DeviceAware` reserved) with a stable `as_str` mapping and
    an `is_global` classifier.
  * SDD-087: `PttBackendDescriptor::focused()` constant value for
    the universal Focused-PTT fallback. The struct shape carries
    only privacy-safe fields (`level`, `backend_id`,
    `bound_input_class`) — a key code cannot fit through this
    surface by construction (DEC-027).
  * SAD-077 / SDD-090: `RedactingLogLayer` now hosts the
    `PttBanCheckVisitor` and the `PTT_BANNED_FIELDS` constant
    (`key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`,
    `keysym_string`, `key_sequence`, `key_press_history`,
    `key_timing`). Any record whose field set names a banned key
    is dropped before reaching the in-memory log sink or the
    user-initiated diagnostic export. The check is structural and
    runs ahead of formatting / redaction.
  * `SessionEvent::PttCapability` carries the diagnostics-safe
    descriptor through the broadcast event stream;
    `chanora_core::ChanoraSession::start_audio` publishes the
    Focused-PTT descriptor when the audio engine starts (SRS-196
    / SDD-091).
  * `BridgeEvent::PttCapability` mirrors the event across the
    FFI boundary. flutter_rust_bridge codegen regenerated.
  * Flutter `_AudioControls` renders a capability badge above the
    PTT button: a globe icon for Global levels, a focus-frame
    icon for `L0Focused`, plus a Tooltip exposing the bound input
    class. New ARB key `pttCapabilityBadge(level, backend)` in
    `app_en.arb` and `app_zh.arb`.

Per-platform global PTT backends (`WindowsRawInputBackend`,
`MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) and the
`MissedKeyUpWatchdog` task land in a separate follow-up commit;
this milestone ships only PTT-L0 universally so the application's
runtime capability reporting is honest from day one.

Tests
-----

* `chanora_audio` rises from 1 to 4 unit tests covering
  `PttCapabilityLevel::as_str`, `is_global`, and the
  `PttBackendDescriptor::focused()` shape contract.
* `chanora_diagnostics` rises from 9 to 11 unit tests covering
  the new `PttBanCheckVisitor` over every banned field name and
  the `PTT_BANNED_FIELDS` stability assertion.
* Workspace total: 53 unit + integration tests, all green with
  `CHANORA_DISABLE_KEYRING=1` (was 49 at v1.0.0-rc.2).
* `flutter analyze`: clean.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate`: zero warnings (license inventory
  regenerated).
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
  LICENSE.
* Linux x86_64 release bundle builds clean.

No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
2026-05-15 15:02:03 +08:00
EdisonJwa 50768a8f48 feat(mvp): v1.0.0-rc.1 — keyring-backed DEK, encrypted bookmarks, MVP release-gate docs
Closes the v0.4 dual-file weakness in identity-at-rest and turns the
release into an MVP public release candidate. The remaining work
before `v1.0.0` is DEC-012 legal sign-off — see
`docs/governance/legal-review-readiness.md` — and the staged
platform promotions in `docs/governance/staged-release-plan.md`.
No decision rows in `product-decision-register.md` change; the
register's change-history advances to 0.9.8.

`chanora_storage`
-----------------

* New public `Crypto` trait + `IdentityFileStore::crypto()` give
  callers an encrypt / decrypt pair anchored on the per-install
  32-byte DEK without exposing the key material.
* `IdentityFileStore` keyring-first DEK retrieval (Linux Secret
  Service via D-Bus, macOS Keychain, Windows Credential Manager,
  iOS Keychain via the `keyring` crate). Pre-existing
  `identity.dek` files are opportunistically migrated into the
  keyring on first run; the on-disk DEK copy is removed once the
  keyring acknowledges. `CHANORA_DISABLE_KEYRING=1` forces the
  file-fallback path for tests and headless / CI hosts where a
  real keyring call would prompt the user or block on a missing
  D-Bus session.
* `BookmarkRepository::with_crypto(dir, crypto)` encrypts the
  server password into a new `password_blob` BLOB column under
  the same per-install DEK. Schema v2 migration is idempotent —
  legacy v0.4 rows with a plain `password TEXT` are read
  transparently and lifted into `password_blob` on the next
  `update()`. `BookmarkRepository::new` (no crypto) is preserved
  for tests and as a documented fallback when the DEK is
  unreachable.
* Storage tests rise from 8 to 10: encrypted bookmark password
  round-trip + legacy-plaintext-bookmark upgrade.

`chanora_core`
--------------

* `ChanoraSession::init_storage(dir)` wires the bookmark
  repository with crypto by default. On any crypto-derivation
  failure it falls back to the plain-password repository and
  logs the gap — better than hard-failing init.
* `supervisor_loop` now tracks a 64-bit `snapshot_signature` over
  channels (id + parent + order + name) and clients (id + channel
  + name) instead of the old `(channel_count, client_count)`
  tuple. Any in-channel client move, channel rename, or reorder
  now fires `SessionEvent::SnapshotChanged`. The signature sorts
  by id before hashing so it's stable under input-vector
  reordering.
* Two new unit tests cover the signature behaviour; new
  `tests/mvp_storage.rs` integration test drives
  `ChanoraSession::init_storage` end-to-end and verifies the
  bookmark `password_blob` does not contain the plaintext.
* Re-export `ChannelId` + `ClientId` from `chanora_protocol` so
  downstream callers and tests can construct DTOs directly.

Flutter
-------

* New About dialog (info icon in the AppBar) surfaces DEC-018
  (public name "Chanora"), DEC-019 (non-affiliation statement),
  and DEC-020 (Apache-2.0 OR MIT dual license). New ARB keys in
  `app_en.arb` and `app_zh.arb`: `aboutAction`, `aboutVersion`,
  `aboutNonAffiliation`, `aboutLicenseHeading`, `aboutLicenseBody`,
  `aboutThirdPartyHeading`, `aboutThirdPartyBody`.
* `pubspec.yaml` version bumps to `1.0.0-rc.1+5`.

Governance
----------

* `docs/governance/legal-review-readiness.md` — DEC-012 handoff
  package. Enumerates trademark / non-affiliation / license-text
  / third-party-attribution / `tsclientlib`-posture / crypto-
  export / data-handling items the legal reviewer must confirm,
  and lists the concrete engineering deliverables they block on
  (`cargo about generate`, `cargo deny check licenses`,
  Flutter `LicenseRegistry` dump).
* `docs/governance/staged-release-plan.md` — DEC-002 channel
  schedule. Linux + Android sideload promote to GA on DEC-012
  sign-off; Play Store / Windows / macOS / iOS gate on per-
  platform signed-build availability. Rollback policy included.
* `product-decision-register.md` change-history advances to
  0.9.8 with a single entry summarising v0.3, v0.4, and v1.0-rc.1
  progress against DEC-001. No decision rows mutate.

Build + ops
-----------

* `NOTICE` refreshed for the MVP product-code dependency set:
  adds `chacha20poly1305`, `rand`, `zeroize`, `base64`,
  `keyring`, `connectivity_plus`, `path_provider`,
  `freezed_annotation`; drops PoC-only entries.
* `CHANGELOG.md` restructured: explicit version sections for
  v0.3.0-beta.1, v0.4.0-beta.2, v1.0.0-rc.1. Previous "Unreleased"
  contents migrated into their respective milestone sections.
* `.github/workflows/ci.yml` exports `CHANORA_DISABLE_KEYRING=1`
  for the cargo-test job — CI runners have no D-Bus session and
  the keyring crate would otherwise block.
* `run-chanora.sh` reads `CHANORA_BUNDLE_FLAVOUR` (default
  `release`) and self-copies the latest cdylib into the bundle's
  `lib/` if missing.

Verification
------------

* `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`: all
  green (49 unit tests across the workspace; up from 36 at
  v0.4.0-beta.2).
* `cargo test -p chanora_core --release -- --ignored alpha_smoke`
  passes against the live `cn.teamspeak.app` (DNS → connect →
  snapshot → disconnect in ~2.5 s).
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` + `flutter build
  linux --release` produce a working Linux x86_64 bundle.

No Android live test in this commit per the user's note that the
physical device was removed; the Android arm64-v8a build path is
mechanically identical to v0.4.0-beta.2.
2026-05-15 02:24:42 +08:00
EdisonJwa 780fd7eca2 feat(beta): External Beta — passwords, channel join, mute, bookmarks, encrypted identity
The v0.3 client could only ever connect to a hardcoded default
channel with no password and offered no controls mid-call.
External Beta closes those gaps and tightens identity-at-rest.

User-facing additions
---------------------

* **Server password** on the connect form. Plumbed through
  `BridgeError`-aware `connect(host, nickname, password)`. Empty
  string means "no password" — no behaviour change for open
  servers.
* **Channel join**: tapping a row (or its login icon) in the
  channel tree issues a `client_move`. Names containing "🔒" or
  "password" prompt for a channel password first.
* **Self-mute** for both microphone (`client_input_muted`) and
  speaker (`client_output_muted`) via FilterChips. Output mute
  also flips the audio engine's local output-muted flag so
  playback silences immediately, before the server acknowledges.
* **Master output gain** slider (0–200%). Plumbed through an
  `AtomicU32` (f32 bits) on the engine that the cpal output
  callback multiplies into every sample.
* **Bookmarks**: SQLite-backed list with Save / Connect / Delete
  actions. Bookmarks persist across app restarts; tapping one
  pre-fills the form and dials immediately.

Hardening
---------

* **Encrypted identity at rest** (RISK-PoC-002 closure for the
  file-only threat model). ChaCha20-Poly1305 envelope: nonce +
  ciphertext written atomically with mode 0600; 32-byte DEK in a
  separate `identity.dek` file. Legacy plaintext identity files
  are auto-detected, read, and upgraded on the next save. Full OS-
  keyring integration is still v0.4 work — documented in the
  store's doc comment.
* **Mobile voice-comm routing**: on Android, `AudioEngine::start`
  uses JNI to set `AudioManager.setMode(MODE_IN_COMMUNICATION)`
  when `cfg.mobile_voice_preset` is true (default). This engages
  the device-side AEC/NS pipeline on most Pixel/Moto/Samsung
  hardware even though cpal still opens the AAudio default input
  preset. Full `setInputPreset(VOICE_COMMUNICATION)` switch is
  still RISK-AUDIO-MOBILE-001 (needs cpal upstream or an Oboe
  fork).
* **Log noise**: bridge default `EnvFilter` now silences
  `tsproto::resend=error` and `tsproto::packet_codec=error` so
  the redacted diagnostic export is human-readable. Still
  overridable via `RUST_LOG=...`.

Engineering
-----------

* **`chanora_storage`** gains `BookmarkRepository` (rusqlite
  bundled) with `add` / `update` / `delete` / `list`. The
  identity store now layers on `chacha20poly1305` + `rand` +
  `zeroize` for the envelope.
* **`chanora_protocol`** exposes `move_to_channel` and
  `set_muted` on `ProtocolClient`, dispatched through the
  existing `connection_task` request channel onto tsclientlib's
  generated `client.client_move(...)` and
  `state.client_update().set_input_muted/set_output_muted(...)`
  paths.
* **`chanora_core::ChanoraSession`** wires the bookmark store
  next to the identity store inside `init_storage`, and adds
  `list_bookmarks` / `add_bookmark` / `update_bookmark` /
  `delete_bookmark` / `move_to_channel` / `set_self_muted` /
  `set_output_gain`.
* **`chanora_audio::AudioEngine`** carries `output_gain` and
  `output_muted` atomics; the output callback consults both. The
  Android branch of `start()` engages MODE_IN_COMMUNICATION via
  a small JNI helper that reuses the `ndk_context` global set by
  the bridge's `android_init` hook.
* **`chanora_bridge::api`** adds `set_input_muted`,
  `set_output_muted`, `set_output_gain`, `move_to_channel`,
  `list_bookmarks`, `add_bookmark`, `update_bookmark`,
  `delete_bookmark`, and the `BridgeBookmark` DTO. FRB v2.12
  codegen regenerated.

Tests + CI
----------

* `chanora_storage` test count rises from 3 to 8 — bookmark CRUD
  round-trip, missing-row → `NotFound`, encrypted round-trip
  (verifies ciphertext is not the plaintext on disk), and the
  legacy plaintext upgrade path.
* New `.github/workflows/ci.yml`: `cargo check --workspace`,
  `cargo test --workspace --no-fail-fast`, `cargo clippy`
  (advisory), `flutter analyze`, and `flutter test` excluding
  the live-server `e2e` tag.

Live-verified on Moto G Stylus 5G against cn.teamspeak.app:
saved a bookmark, reconnected via it, joined a non-default
channel via tap, toggled both mutes, slid the volume, and the
redacted diagnostic export confirmed `AudioManager mode set to
MODE_IN_COMMUNICATION`, `client_move sent`, and `client_update
sent` lines.
2026-05-15 01:59:27 +08:00
EdisonJwa 43a3c9ba76 feat(events): A.4 — emit SnapshotChanged from the watchdog probe
Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.

* `chanora_core::SessionEvent::SnapshotChanged { channels, clients }`
  carries the latest channel and client counts.
* The supervisor compares the probe result to `last_counts` and
  fires the event only when the count actually changes. `last_counts`
  is reset to `None` on a successful reconnect so the freshly
  dialled session re-emits its initial counts.
* `chanora_bridge::api::BridgeEvent::SnapshotChanged` is the
  cross-bridge mirror.
* Flutter routes the event through `_onEvent`, which calls
  `_onRefresh()` to repopulate the snapshot view.

The probe-driven detection has known limits — pure within-channel
client moves do not change the count and so are not surfaced. That
gap will close when the supervisor tracks a content hash in
addition to the count; the count-only signal is sufficient for the
common "someone joined / someone left" case observed on cn.teamspeak.app.
2026-05-15 01:27:57 +08:00
EdisonJwa d2d9ba0a5b feat(diagnostics): A.3 — redacted in-memory log sink + user-initiated export
Replaces the diagnostics scaffold with the production redaction
policy + a user-initiated export path that satisfies DEC-016 (no
automatic uploads).

* `chanora_diagnostics::Redactor` applies the six policy rules to
  every captured log line: `$HOME` paths → `[home]`; IPv4 + IPv6
  literals → `[ip]`; email-shaped strings → `[email]`; long
  base64-ish tokens → `[token]`; substrings registered with
  `KnownSecretRegistry` → `[REDACTED]`. The registry implements
  SS-AUD-003 defence-in-depth: storage adapters can register
  secrets as they cross out of the keyring so an accidental
  `Debug` print is still scrubbed at write time.
* `InMemoryLogSink` is a bounded ring buffer (cap 500 lines in the
  bridge) that always passes lines through the redactor before
  storing them. `RedactingLogLayer` plugs it into `tracing-
  subscriber` alongside the existing logcat / fmt layers.
* `DiagnosticExport::from_sink` builds a plaintext blob — already
  redacted — combining free-form metadata (crate version, target
  os/arch) with the retained log tail. `bridge::api::
  export_diagnostics()` is the Flutter-facing entrypoint
  (`#[frb(sync)]`).
* `bridge_init` now installs the redaction layer on both Android
  and desktop hosts, switching from the global `fmt::init()`
  shortcut to a layered `Registry` so the in-memory sink can sit
  side-by-side with the platform sink.
* Flutter adds a bug-report icon to the AppBar; tapping it opens a
  scrollable monospace dialog with Copy and Close actions. New
  `diagnosticsAction` / `copyAction` / `closeAction` strings land
  in `app_en.arb` + `app_zh.arb`.

Tests cover the redaction matrix (IPv4, IPv6, email, long tokens,
known secret), the ring buffer capacity, and the full
`DiagnosticExport::to_text()` round-trip — 9/9 green.

Live-verified on Moto G: the dialog rendered a multi-line transcript
with `[ip]`, `[token]`, `[home]` substitutions, the metadata block
showed `target_os=android` `target_arch=aarch64`, and Copy placed
the same text on the clipboard.
2026-05-15 01:26:49 +08:00
EdisonJwa 71ecb83781 feat(storage): A.2 — persist TS3 identity across app restarts
A fresh `Identity::create()` was generated on every connect, which
meant the server saw a different client UID each time. Long-lived
features (bookmarks, server-side bans, group membership) depend on a
stable UID — restoring that now via a minimal directory-backed
identity file.

* `chanora_storage::IdentityFileStore` reads / writes a single
  `identity.tskey` file under a caller-supplied directory. On Unix
  the file is created with `O_CREAT | O_TRUNC | mode 0600`; on
  non-Unix targets the platform sandbox does the access control.
  Writes are atomic (temp file + `fsync` + `rename`) so a crash
  mid-write cannot leave a half-written identity on disk. Empty
  files are treated as "no identity" rather than as an error.
* `chanora_protocol::ProtocolClient::generate_identity()` exposes
  the `counterVbase64key` serialisation used by tsclientlib's
  `Identity::new_from_str`, so the core layer can mint an identity
  and store it before dialling.
* `chanora_core::ChanoraSession::init_storage(dir)` wires the
  store. `connect()` then resolves the identity in this order:
  (1) `cfg.identity` if explicitly supplied; (2) persisted value if
  any; (3) generate-and-persist a fresh one.
* `chanora_bridge::api::init_storage(dir: String)` is the
  Flutter-facing entrypoint; the matching Dart side resolves
  `path_provider`'s `getApplicationSupportDirectory()` and calls
  it once on app start.
* `BridgeError` now maps `CoreError::Storage`.

Beta caveat (RISK-PoC-002 / SS-RISK-FALLBACK): the identity is not
encrypted at rest. The v0.4 storage rework lands proper Secret
Service + Android Keystore + iOS Keychain backends. Documented
under `IdentityFileStore`'s doc comment.

Live-verified on Moto G Stylus 5G: first connect generated +
persisted the identity (visible in the redacted diagnostic export
as "generated + persisted fresh identity"); disconnect + reconnect
in the same session logged "reusing persisted identity" and dialled
with the same UID.
2026-05-15 01:25:07 +08:00
EdisonJwa f52d702e27 feat(core): A.6.1 — use OS connectivity signals to drive reconnect
Extends the A.6 supervisor with an OS-level connectivity hint so a
returning network triggers a redial immediately instead of waiting
out the current backoff slot (up to 60 s). The watchdog remains the
authoritative loss detector — the OS signal is advisory.

* `chanora_core::NetworkState` (Unknown / Online / Offline) is owned
  by `ChanoraSession` via a `tokio::sync::watch::Sender`.
  `set_network_state()` / `network_state()` are the public accessors.
* The supervisor's watch-phase `select!` gains a `network_rx`
  branch: Offline pre-charges watchdog misses (capped at
  `MAX_MISSES - 1`) so the next probe failure trips immediately;
  Online clears stale misses. This shrinks UI-banner latency on a
  Wi-Fi drop from ~15 s to ~5 s.
* The reconnect-loop's backoff sleep races against Online: a
  transition cuts the sleep short and resets the attempt counter so
  future losses start at the smallest backoff window again.
* `chanora_bridge` adds `BridgeNetworkState` (mirror enum) and a
  sync `set_network_state(state)` function. On platforms with no
  signal wired the supervisor stays at Unknown and falls back to
  pure watchdog/backoff — no behavioural regression vs A.6.
* Flutter adds `connectivity_plus ^6.1.0` and wires
  `_wireConnectivity()` in `main()`: seeds with `checkConnectivity()`
  then forwards every `onConnectivityChanged` to the bridge,
  mapping any non-`none` transport to Online.

Verified on Moto G Stylus 5G (Android 14): `svc wifi disable && svc
data disable` for ~40 s — reconnect banner appeared promptly
because the watchdog was pre-charged. After `svc wifi enable && svc
data enable` the supervisor woke from its 15 s backoff slot and
reconnected within seconds; the channel tree re-rendered without
user action.
2026-05-15 01:07:21 +08:00
EdisonJwa 0bef61aea2 feat(core): A.6 — supervisor reconnect with watchdog and event stream
Adds an end-to-end auto-reconnect path so a brief network outage no
longer leaves the client wedged in a half-dead state. The flow has
three layers, each motivated by a real failure mode observed on the
Moto G live test:

* `chanora_protocol::DisconnectReason` (`UserRequested` /
  `StreamEnded` / `Error(String)`) is reported on a `oneshot` when
  the per-connection task exits, so the supervisor can tell user
  intent apart from a real loss.
* `chanora_core` spawns a supervisor task per `ChanoraSession`. It
  listens for the loss notifier AND runs a watchdog that issues
  `snapshot()` probes every 5s with a 4s timeout — three consecutive
  misses synthesise a `DisconnectReason::Error(...)` and trigger the
  reconnect path. The watchdog catches the "ghost connected" case
  where tsclientlib silently resets internal state but the event
  stream never errors. Backoff schedule: 1s, 2s, 5s, 15s, 30s, 60s
  (capped). On success the supervisor swaps the dead `ProtocolClient`
  for the new one in place and, if audio was running, restarts the
  audio engine bound to the new `voice_in`/`voice_out` channels.
* `SessionEvent` (Connected / Lost / Reconnecting / Disconnected /
  AudioStarted / AudioStopped) is broadcast on a 64-slot channel.
  `chanora_bridge` re-exports it as `BridgeEvent` and exposes
  `events_stream(StreamSink)`; the Flutter side subscribes from
  `initState` and renders a reconnect banner with attempt count and
  delay. New `SnapshotProbe` exposes a clone-friendly snapshot path
  so the watchdog can probe without holding `&self` across awaits.

Localization adds `statusReconnecting` and `statusConnectionLost`
keys to `app_en.arb` and `app_zh.arb`.

Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app:
killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three
misses, supervisor walked the backoff schedule, and the UI
reconnected automatically once the radios came back. Snapshot tree
re-rendered without user action.
2026-05-15 01:06:07 +08:00
EdisonJwa c81ccfd9a9 feat(android): produce v0.2.0-beta.1 Android APK with voice in/out
Builds the Internal Beta product app for Android. Companion to the
Linux desktop build already shipped at the same tag.

What this commit adds to the source tree:

  crates/chanora_bridge/src/android_init.rs (new):
    JNI lifecycle for Android. JNI_OnLoad captures the JavaVM*.
    Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext
    is called by MainActivity.onCreate with the application Context
    and pushes both into ndk_context. Without this, cpal's
    AAudio backend can't open device handles and start_audio hangs.

  crates/chanora_bridge/Cargo.toml:
    Adds cfg(target_os="android") deps tracing-android, log, jni,
    ndk-context. Linux/desktop builds are unaffected.

  crates/chanora_bridge/src/lib.rs:
    Conditionally includes the android_init module on Android.

  crates/chanora_bridge/src/api.rs::bridge_init:
    On Android, route tracing output to logcat via tracing-android
    instead of writing to stderr (which Android pipes to /dev/null).
    Logs show under `adb logcat -s chanora`.

  apps/chanora_flutter/android/app/src/main/AndroidManifest.xml:
    Adds uses-permission android.permission.INTERNET (needed for
    the protocol layer) and android.permission.RECORD_AUDIO (needed
    by chanora_audio's capture stream). Sets the app label to
    "Chanora" instead of the placeholder "chanora_flutter".

  apps/chanora_flutter/android/app/src/main/kotlin/.../MainActivity.kt:
    Overrides the Flutter-generated MainActivity. Loads
    libchanora_bridge.so eagerly at class-init so JNI_OnLoad runs
    before any FRB call. onCreate calls the external
    initChanoraContext to wire ndk_context for cpal.

  apps/chanora_flutter/pubspec.yaml:
    Bumps version 1.0.0+1 → 0.2.0+2 to match the v0.2.0-beta.1 tag.

  run-chanora.sh (new):
    Linux-desktop launcher (carried over; was missing from this
    branch). Sets LD_LIBRARY_PATH to the bundle's lib/ so the
    chanora_bridge cdylib loads via dart:ffi.

Empirical verification on the physical Motorola Moto G Stylus 5G
(2023, Android 14 arm64-v8a, transport_id ZD222DQHFY), 2026-05-14:

  - APK installed via adb install.
  - Activity launched; permissions granted.
  - Connect form filled with 175.178.125.23 (Vigorous Pro's IP —
    see honest limitation below); Connect button tapped.
  - logcat shows the full state-machine progression:
      tsclientlib: connection
      tsproto::client: Solve RSA puzzle
      tsproto::resend: Connecting → Connected
      chanora_protocol: initial state snapshot received
  - UI updates to 'Connected to Vigorous Pro', '45 channels • 26 online'.
  - Welcome banner with CJK characters preserved verbatim.
  - 'Start audio' tapped:
      AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#1
      AAudio: AAudioStream_requestStart(s#1) returned 0
      AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#2
      AAudio: AAudioStream_requestStart(s#2) returned 0
      AAudioStream: setState s#1 from 3 to 4 (Started)
      AAudioStream: setState s#2 from 3 to 4 (Started)
  - PTT button held for 2.5 s:
      UI shows: 'TX 124 frames • RX 0 frames • PTT off'.
    124 frames / 2.5 s ≈ 50 frames/s = 20 ms Opus frames — exactly
    the encoder cadence. Voice transmission proven over UDP to the
    real server.

Honest limitation surfaced during verification:

  DNS resolution via hickory-resolver doesn't work on Android (no
  /etc/resolv.conf). Connecting by hostname produces:
    BridgeError.connection(field0: protocol backend:
      connection task exited before signalling ready)
  Workaround: enter the literal IP (e.g. 175.178.125.23 for
  cn.teamspeak.app). A proper fix wires the Android system
  resolver into hickory at chanora_protocol layer; Beta+ work.

Build prerequisites (documented for reproducibility):

  - Android NDK r26.3.11579264 at /opt/android-sdk/ndk/26.3.11579264.
  - rustup targets: aarch64-linux-android, armv7-linux-androideabi,
    x86_64-linux-android.
  - cargo-ndk 4.x.
  - Pre-built libopus.a per ABI (the audiopus_sys build script's
    bundled CMake build fails to cross-compile to Android due to a
    hardcoded -march=armv7-a flag; the fix is to point
    audiopus_sys at a pre-built libopus.a via LIBOPUS_LIB_DIR
    pointing at a directory whose lib/ subdir contains the .a).
    Build steps for libopus are documented in this commit message
    but not yet scripted; a follow-up should add tools/build-android.sh.
  - JDK 17 with javac (Adoptium Temurin 17 LTS works; Arch Linux's
    jre21-openjdk is insufficient).

ABIs built and shipped in the APK:
  arm64-v8a, armeabi-v7a, x86_64.

Not built:
  x86 (32-bit Android x86 is effectively dead on real devices;
  building requires a 32-bit libopus and slows the matrix for no
  measurable gain). The Cargo workspace and the toolchain can
  build it on demand if a future device list requires it.
2026-05-14 23:40:58 +08:00
EdisonJwa 9790005c3e feat(beta): wire voice in/out end-to-end with push-to-talk (v0.2.0-beta.1)
Reaches the Internal Beta milestone of DEC-001's release sequence the
same day as Alpha. Adds voice capture and playback through the full
Flutter UI → FRB → Rust core → tsclientlib → server path.

Promotions from PoC:
  poc/audio-capture-playback-spike  →  crates/chanora_audio/

New product code:
  crates/chanora_audio/src/engine.rs — cpal capture and playback,
    audiopus Opus VoIP encoder (48 kHz mono 20 ms frames), tsclientlib
    AudioHandler for decode + jitter buffer + mix on playback,
    push-to-talk gate, graceful playback-only fallback when capture
    is unavailable.
  crates/chanora_protocol/src/adapter.rs — extended with
    voice_out_tx (clonable mpsc::Sender<OutPacket>) and
    take_voice_in() (one-shot mpsc::Receiver<InboundVoice>); main
    loop now interleaves outbound voice drain, event pumping, and
    control-request handling.
  crates/chanora_protocol/src/lib.rs — re-exports the few
    tsproto_packets types (OutAudio, OutPacket, InAudioBuf,
    AudioData, CodecType, Direction) that chanora_audio
    legitimately needs. Documented as the single deliberate
    cross-crate type re-export per SAD-067, justified by the
    performance cost of a parallel type hierarchy on the 20 ms
    voice frame.
  core/chanora_core/src/lib.rs — ChanoraSession::start_audio,
    set_ptt, audio_stats; disconnect now stops the engine first.
  crates/chanora_bridge/src/api.rs — startAudio, setPtt,
    audioStats commands and BridgeAudioStats DTO.
  apps/chanora_flutter/lib/main.dart — "Start audio" button +
    hold-to-talk PTT button with pressed/released visual state +
    live stats line (TX/RX/PTT). Stats polled every 500 ms.

ARB:
  Both en and zh-Hans gain startAudioAction, pttHoldToTalk,
  pttTransmitting, audioStatsLine. Banner updated to
  "Beta build — voice in/out wired; not production ready."

FRB config:
  flutter_rust_bridge.yaml gains local: true so codegen resolves
  the workspace member's library stem to "chanora_bridge" instead
  of falling back to "UNKNOWN".

Empirical verification (2026-05-14, against cn.teamspeak.app):
  cargo check + cargo test --workspace: all green.
  flutter analyze: 0 issues.
  flutter test: 4/4 passing including:
    - test/alpha_e2e_test.dart (regression: Alpha still works)
    - test/beta_e2e_test.dart (Beta: connect → startAudio →
      PTT cycle → disconnect against cn.teamspeak.app).
  Live smoke (cargo test alpha_smoke -- --ignored): 49 channels,
  37 clients retrieved.
  Capture stream open against the host PipeWire auto_null source
  refused (snd_pcm_hw_params); engine correctly logged the warning
  and continued in playback-only mode. TX=0 frames, RX=0 frames
  reflects the headless null-source environment; on a real mic
  host the encoder produces ~50 frames/second while PTT is held.

Honest Beta scope (NOT in this release):
  - AEC / AGC / NS / HPF DSP (DEC-007..010): AudioEffects exists
    as a struct but the filters are no-ops. Beta+ work.
  - Production-quality resampler: current code is linear
    interpolation. Beta+ work.
  - Identity persistence via chanora_storage: still ephemeral.
  - Push-to-Dart event stream: UI polls instead.
  - chanora_diagnostics tracing-layer wiring: still scaffold.
  - Mobile (Android) cdylib + UI: PoC-proven, not yet in product.
  - Reconnect / network-loss recovery for the voice path.

Docs updates:
  - docs/governance/product-decision-register.md bumped to v0.9.7
    (Beta-milestone change-history entry; no row changes).
  - docs/governance/poc-results-summary.md bumped to v0.6.0
    (RISK-PoC-005 updated with Beta progress).
2026-05-14 22:43:57 +08:00
EdisonJwa 4915ec0a1b feat(alpha): wire connect→snapshot→disconnect end-to-end (v0.1.0-alpha.1)
First Internal Alpha build per DEC-001. Closes the milestone of
'Flutter UI calls Rust via the typed bridge, Rust connects to a
TeamSpeak-compatible server through tsclientlib, returns a typed
snapshot, and disconnects cleanly.' Audio remains Beta scope.

Promotions from PoC:
  poc/tsclientlib-connect-spike  →  crates/chanora_protocol/

New product code:
  crates/chanora_protocol/src/{dto.rs,adapter.rs} — typed boundary
    over tsclientlib. Tokio task owns the Connection; public
    handle communicates via mpsc/oneshot. No tsclientlib types
    cross out of the crate (SAD-067 / SysDes-011 / SysDes-029).
  core/chanora_core/src/lib.rs — ChanoraSession composes the
    protocol crate, enforces the DEC-006 single-connection
    invariant.
  crates/chanora_bridge/src/{api.rs,frb_generated.rs} — FRB 2.12.0
    bridge per DEC-014. cdylib + staticlib + rlib. Typed
    BridgeSnapshot / BridgeChannel / BridgeClient / BridgeError
    DTOs. Process-wide OnceLock<Runtime> + OnceLock<ChanoraSession>.
  flutter_rust_bridge.yaml at repo root.
  apps/chanora_flutter/lib/main.dart — Alpha UI: server form,
    connect button, channel tree, disconnect.
  apps/chanora_flutter/lib/l10n/app_{en,zh}.arb expanded with the
    Alpha key set; ARB metadata reaffirms ADR-008 for
    server-provided content.
  Generated Dart bindings under apps/chanora_flutter/lib/src/rust/.

Empirical verification (2026-05-14):
  Workspace: cargo check + cargo test clean
    (workspace tests: all green).
  Bridge cdylib: target/release/libchanora_bridge.so produced
    (~15 MB).
  Flutter: flutter analyze clean; flutter test runs 3/3 green
    including the alpha_e2e_test that drives the full
      Dart → FRB → chanora_bridge → chanora_core → chanora_protocol
        → tsclientlib → UDP → cn.teamspeak.app
    path. The captured logcat/stdout shows the tsproto resender
    transitioning Connected → Disconnecting → Disconnected on
    clean teardown.

Architecture changes:
  - Removed the chanora_core ↔ chanora_bridge cyclic dependency.
    chanora_core no longer knows the bridge exists; the bridge
    maps from CoreError.
  - chanora_bridge crate's #![forbid(unsafe_code)] lint relaxed
    because FRB-generated glue legitimately uses unsafe at the
    FFI boundary. Hand-written code remains unsafe-free.

Open follow-ups (NOT in this Alpha):
  - Audio capture/playback wiring into chanora_audio
    (Beta scope per DEC-001).
  - Identity persistence via chanora_storage
    (currently regenerated on every connect).
  - Per-message diagnostics + redaction
    (chanora_diagnostics still scaffold).
  - Reconnect / network-loss recovery.
  - Mobile (Android) build of the bridge cdylib + UI verification.
2026-05-14 21:37:06 +08:00
EdisonJwa 974dda9601 feat(scaffold): create product workspace + Rust crates + Flutter app
Implements the canonical implementation directory layout adopted by
DEC-022 (register v0.9.5). Closes the scaffolding phase; no PoC code
has been promoted in yet (per proof-of-concept-plan.md §4 a PoC is
not product code unless explicitly promoted).

Rust workspace
==============

Top-level Cargo.toml declares seven workspace members:

  core/chanora_core           top-level Rust API + orchestration
  crates/chanora_protocol     tsclientlib isolation (SAD-067, SysDes-011/029)
  crates/chanora_state        state sync, reducers, deltas
  crates/chanora_audio        capture, DSP, Opus, jitter, mixer, playback
  crates/chanora_storage      non-secret DB + platform secure store
  crates/chanora_diagnostics  logs, redaction, export
  crates/chanora_bridge       typed Flutter/Rust DTOs

Workspace-wide pins:
  license      = "MIT OR Apache-2.0"   (DEC-020)
  rust-version = "1.95"
  edition      = "2021"

The Flutter app (apps/chanora_flutter) is NOT a Cargo workspace
member; it is owned by the Flutter / Gradle toolchain and is in the
workspace exclude array along with every poc/* spike.

Each crate ships:
  * a Cargo.toml referring to workspace.dependencies pins;
  * a lib.rs with #![forbid(unsafe_code)] + #![warn(missing_docs)],
    a typed Error enum, and the public types relevant to the
    subsystem's role per SAD §7.2;
  * minimal unit tests so
running 1 test
test tests::defaults_match_decisions ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::it_compiles ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::session_can_be_constructed ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::marker_matches_poc ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::it_compiles ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::state_transitions_compile ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test tests::it_compiles ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s is non-empty.

chanora_core's CoreError type-wraps every subsystem error via
#[from] so callers can match on origin without parsing strings.

chanora_audio's AudioEffects struct defaults all four effects to
true, matching DEC-007 (AEC), DEC-008 (AGC), DEC-009 (NS),
DEC-010 (HPF). A unit test pins this so a future regression that
flips a default fails immediately.

chanora_diagnostics exports REDACTION_MARKER = "[REDACTED]",
identical to the PoC's marker so audit grep patterns survive the
promotion.

chanora_bridge's BridgeError is Serialize + Deserialize so it can
flow across the FRB 2.x boundary (DEC-014).

Empirical verification: cargo check --workspace clean, cargo test
--workspace clean (7 unit tests + 7 doc-test runners, all passing)
against Rust 1.95.0 stable.

Flutter app
===========

apps/chanora_flutter created with .

DEC-004 applied: minSdk overridden to 28 in
android/app/build.gradle.kts with a comment that points back at the
decision register and forbids lowering it without re-opening DEC-004.

DEC-015 applied: shipped English + Simplified Chinese at MVP.
  - pubspec.yaml gains flutter_localizations + intl + generate:true.
  - l10n.yaml emits lib/l10n/generated/AppL10n (no synthetic package
    — that was removed in Flutter 3.41+).
  - lib/l10n/app_en.arb is the source of truth; lib/l10n/app_zh.arb
    mirrors the key set in zh-Hans. ARB metadata explicitly
    reaffirms ADR-008: server-provided content (channel names,
    nicknames, welcome banners) is preserved verbatim and never
    translated.

lib/main.dart and test/widget_test.dart were rewritten from the
template counter into a minimal localized scaffold that proves
both locales render correctly.

Empirical verification:  reports no issues;
 runs the two locale smoke tests and both pass.
2026-05-14 21:01:59 +08:00