Commit Graph
17 Commits
Author SHA1 Message Date
Edison Jwa dc9c5c0a4e feat: multi-platform bug fixes, Android audio path, and build tooling
Flutter UI fixes:
- Fix stale channel badge/speaker when moved by others (derive current
  channel from ownClientId instead of optimistic local state)
- Fix Linux PTT via focused fallback key handler
- Distinguish ServerQuery clients with terminal icon in client list
- Reduce duplicate current-channel badge display
- Prevent PTT key-bind save from permanently closing voice settings
- Fix Linux GTK reopen-after-close (quit app on window destroy)
- Fix focused PTT: consume key events, release held keys on
  disconnect/leave-channel/mode/backend changes, suppress stale errors

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

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

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

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

Rust bridge:
- Guard open_log_file() on non-Android (Android uses logcat)
2026-05-18 00:13:21 +09:00
Edison Jwa 7a59f5b9a1 feat(ios,p0): iOS P0 platform, audio fixes, channel UX 2026-05-17 22:00:00 +09: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 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 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 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 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 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