Commit Graph
117 Commits
Author SHA1 Message Date
EdisonJwa 63cbab901e diag(audio,ios): instrument VPIO render callback to isolate playback breakage (rc.8+66)
User reports capture-side audio (mic -> remote) is clean but
local playback (remote -> speaker via VPIO render callback) is
'broken and poor' at +65. The pipeline appears correct on paper:
fill_buffer -> downmix (L+R)*0.5 -> gain -> clamp -> i16 -> VPIO.
No errors logged. To stop guessing, add structured logging
inside the render callback so the next test cycle yields data
about what's actually flowing through.

Diagnostic emitted every 100th callback (~2 s at iOS's typical
20-50 Hz callback rate):

  ios VPIO render callback diagnostic sample
    cb=<counter>
    num_frames=<N>           VPIO buffer size in mono samples.
                             Expected ~960 (20ms) or ~1104 (23ms).
                             Outliers point at format mismatch.
    peak_stereo_f32=<f32>    Peak |sample| of AudioHandler's
                             output BEFORE gain + downmix.
                             0.0 = handler is producing silence
                                   (jitter underrun, no audio).
                             ~1.0 = full-scale content reaching
                                    the callback as expected.
    peak_out_i16=<i16>       Peak |sample| of the downmixed mono
                             i16 we write to VPIO. Zero with
                             non-zero peak_stereo = downmix bug.
                             Near 32767 = clipping pressure.
    gain=<f32>               Current master output gain.

What we'll be able to diagnose from a 5-second talker session:

* peak_stereo_f32 = 0 throughout
    -> AudioHandler isn't producing samples. Inbound forwarder
       may not be feeding it, or jitter buffer is stuck in
       buffering_samples state. NOT a render-callback bug.

* peak_stereo_f32 oscillating, peak_out_i16 = 0
    -> Downmix or i16 cast is broken. Math bug in the loop.

* num_frames wildly different from ~960-1104
    -> StreamFormat got rejected and VPIO is delivering a
       different rate. Format-pinning fight with the session.

* peak_stereo_f32 normal AND peak_out_i16 normal AND user
  still says 'broken'
    -> The signal reaches the device cleanly but iOS's VPIO
       output processing (AEC residual subtraction, AGC
       compression, NS gate) is mangling it after our callback
       returns. That's a VPIO-config problem, not a render-
       callback problem; fix is to disable specific VPIO
       voice-processing properties on the unit before
       initialize().

Pure diagnostic commit. No behavioural change beyond a
warn-rate-limited info log line every ~2 seconds. Cost in the
audio thread is one branch + counter increment + (every 100th)
a tracing macro invocation.

Build counter 65 -> 66.
2026-05-17 01:26:15 +08:00
EdisonJwa ecf9370db1 fix(ui): preserve '-rc.8' in About dialog despite iOS version sanitisation (rc.8+65)
Two related fixes for the version-display work landed at 97a6ba6:

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

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

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

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

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

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

flutter analyze: clean.

Build counter 64 -> 65. After Xcode Clean Build Folder + Run the
About dialog should now read 'v1.0.0-rc.8+65' on the iPhone.
2026-05-17 01:18:06 +08:00
EdisonJwa e7c3ffa6d2 feat(audio,ios): wire VPIO render callback to AudioHandler (commit 4/5, rc.8+64)
Replace the silence-emitting render callback from commit 1 with
real playback that drives AudioHandler::fill_buffer and downmixes
its 48 kHz stereo f32 output to the i16 mono buffer VPIO expects.

Pipeline per render callback (mirrors the cpal-output + sdl_output
contracts so the platform-neutral playback path is preserved):

1. Lock the shared Arc<Mutex<AudioHandler>>, ask fill_buffer to
   populate a stereo-f32 scratch slice of length 2*num_frames.
   AudioHandler runs Opus decode + per-client jitter buffer + mix
   internally. Same primitive every other platform calls.

2. If output_muted is true, zero the i16 output buffer and return.
   We still ran fill_buffer in step 1 so the jitter buffer drains
   while muted — preventing unbounded growth — which matches the
   cpal/SDL backend contract.

3. Downmix stereo -> mono with master gain:
       mono_f32 = (l + r) * 0.5 * gain
       i16_out  = (mono_f32.clamp(-1.0, 1.0) * i16::MAX) as i16
   The 0.5 average preserves total signal energy with 3 dB
   headroom against sum-of-correlated-peaks clipping. Multiply by
   gain after the downmix saves one mul per sample. Hard-clip on
   the i16 cast is acceptable because the upstream stereo signal
   is already in [-1.0, 1.0] from the f32 mix; only gain >1.0
   creates clipping pressure and that path is identical to every
   other backend's i16 conversion.

Closure ownership:
* scratch_stereo: Vec<f32> moved into the FnMut closure. First
  callback grows it to 2*num_frames; subsequent callbacks reuse
  the backing allocation. The audio thread never hits the
  allocator on steady-state callbacks.
* handler_for_render / output_gain_for_render / output_muted_for_render
  are Arc clones taken before the closure literal.

Public API change: AudioEngine -> IosVoiceUnit::start parameters
that were previously underscored (commit 1 placeholder) are now
all consumed by the wiring. Signature is unchanged, just the
binder names lose the leading underscore. engine.rs call-site
is unaffected.

Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 0.33s, no errors, no warnings.

Build counter 63 -> 64 — About dialog shows v1.0.0-rc.8+64.
2026-05-17 01:12:57 +08:00
EdisonJwa 1aa514df75 feat(audio,ios): wire VPIO input callback to Opus encoder (commit 3/5, rc.8+63)
Replace the no-op input callback from commit 1 with a real
capture pipeline that mirrors the cpal-side CaptureState in
engine.rs but is type-specialised for the i16 mono samples VPIO
delivers natively.

New IosCaptureState struct (private to ios_voice_unit.rs) owns:
* OpusEncoder configured for VoIP at 48 kHz mono (32 kbps,
  complexity 10, inband FEC, packet-loss-perc 5 — identical
  tuning to try_open_capture in engine.rs).
* pcm_accum: Vec<i16> with capacity 2*FRAME_SAMPLES_MONO, growing
  if a VPIO callback ever delivers more than ~40 ms.
* opus_out: [u8; MAX_OPUS_FRAME] scratch.
* Cloned Arc<AtomicBool> transmit gate + Arc<AtomicU32> frames-sent
  counter shared with AudioEngine.

ingest_i16 flow:
1. If PTT gate is off -> clear accumulator + return (matches cpal
   behaviour, no pop on PTT-release edge).
2. Apply mic_gain. Fast-path when gain==1.0 skips the multiply +
   saturate loop entirely; otherwise saturating mul-then-cast
   keeps the signal in the i16 envelope.
3. Drain complete 20 ms / 960-sample frames from the accumulator,
   encode via encoder.encode (i16 path, no float conversion
   needed since VPIO already gave us i16), build OutPacket with
   AudioData::C2S { codec: OpusVoice }, try_send on voice_out_tx.
4. Frame buffer is stack-allocated [i16; FRAME_SAMPLES_MONO] —
   no per-callback heap allocation on the realtime audio thread.

VPIO setup changes in IosVoiceUnit::start:
* NEW: explicit kAudioOutputUnitProperty_EnableIO (=2003) with
  value 1 on (Scope::Input, Element::Input) BEFORE the stream
  format setters. VPIO's input element is OFF by default; without
  this toggle no audio flows in and the input callback never
  fires. Commit 1's comment claiming set_input_callback handles
  this was wrong; coreaudio-rs's set_input_callback only installs
  the kAudioOutputUnitProperty_SetInputCallback property, not
  the EnableIO toggle.
* Apple's documented sequence (now matched):
  1. AudioComponentInstanceNew -> AudioUnit::new_uninitialized
  2. EnableIO on element 1     -> set_property(2003, ...)
  3. Stream format both elems  -> set_stream_format x2
  4. Install callbacks         -> set_input_callback + set_render_callback
  5. AudioUnitInitialize       -> unit.initialize
  6. AudioOutputUnitStart      -> unit.start
* set_input_callback closure now moves the IosCaptureState in
  by value and calls ingest_i16 with args.data.buffer (the
  &mut [i16] coreaudio-rs delivers after running AudioUnitRender
  internally to pull the mic samples into a pre-allocated
  AudioBufferList).

What this commit does NOT do:
* Output render callback is still a silence-emitting stub.
  Commit 4 lands the AudioHandler::fill_buffer + i16 downmix.
* Route-change handling — commit 5.

Build verify on Mac (target aarch64-apple-ios): cargo check
clean in 1.18s, no errors, no warnings.

Build counter 62 -> 63 — About dialog shows v1.0.0-rc.8+63.
2026-05-17 01:11:11 +08:00
EdisonJwa 9502580b5a chore(audio,ios): silence cpal-side dead_code on iOS + regen Podfile.lock (rc.8+62)
Two follow-ups after the iOS Rust build went green at 5de6ecc:

1. cpal-side framing constants (SAMPLE_RATE / FRAME_SAMPLES /
   MAX_OPUS_FRAME) are dead code in the current iOS commit
   because the VPIO callbacks are still no-op stubs and don't
   reach the constants yet (commits 3 + 4 will). They are
   genuinely live on every other platform via the cpal capture
   pipeline. Mark each with #[allow(dead_code)] and add a
   comment pointing at the commits that will reactivate them on
   iOS, instead of cfg-gating per-platform (the constants are
   framing invariants of the engine itself, not per-backend
   details).

2. ios/Podfile.lock regenerated on the Mac via 'pod install'
   to register package_info_plus (0.4.5) which landed in
   97a6ba6. Without this regen the Xcode build fails with
   'The sandbox is not in sync with the Podfile.lock' because
   Xcode's CocoaPods integration check sees a new plugin in
   pubspec.yaml that has no matching Pod entry. Five pods now
   in the lockfile: Flutter, audio_session, chanora_bridge,
   connectivity_plus, package_info_plus.

Build counter 61 -> 62 — the About dialog will display
v1.0.0-rc.8+62 so the user can confirm the build under test
matches this commit (the previous build said +61).
2026-05-17 01:00:50 +08:00
EdisonJwa 5de6eccc0c fix(audio,ios): correct ios_voice_unit imports + iOS stop() field access (rc.8+61)
iOS build of chanora_audio (target aarch64-apple-ios) failed with
four compilation errors after commit 2 landed. Root causes were
all simple symbol-path / cfg-gating mistakes from the skeleton
commit; the underlying design is unchanged.

1. ios_voice_unit.rs: wrong import path for OutPacket. The
   chanora_protocol crate re-exports it at the crate root
   (`pub use ...::OutPacket` in lib.rs line 52), not from a
   `voice` submodule (which doesn't exist).
   - use chanora_protocol::voice::OutPacket;
   + use chanora_protocol::OutPacket;

2. ios_voice_unit.rs: LinearPcmFlags lives in
   `coreaudio::audio_unit::audio_format`, not in
   `stream_format` (the doc page lists it under StreamFormat but
   the actual module path is the upstream Apple naming).
   - use coreaudio::audio_unit::stream_format::LinearPcmFlags;
   + use coreaudio::audio_unit::audio_format::LinearPcmFlags;

3. ios_voice_unit.rs: `Ordering` import unused (commit 1
   skeleton callbacks don't load atomics yet — that comes in
   commits 3 + 4). Remove from the std::sync::atomic import to
   silence the unused_imports warning.

4. engine.rs::AudioEngine::stop(): the existing body unconditionally
   touched self._input_stream and self._output_stream, but commit
   2 cfg-gated those fields away on iOS (and added an iOS-only
   _ios_voice_unit field in their place). Split the field drop
   logic with the same target_os = "ios" cfg so each platform
   only touches the fields it actually has.

Also clean up two pre-existing warnings exposed by the iOS cfg
gating:

5. engine.rs: `tracing::{error, warn}` were imported
   unconditionally but are only used inside cpal log lines.
   Cfg-gate the import to not(target_os = "ios").
6. engine.rs: `AudioData`, `CodecType`, `OutAudio` from
   chanora_protocol are only referenced in the Opus encoder feed
   inside CaptureState — cpal-side only. Cfg-gate to
   not(target_os = "ios"); keep `InboundVoice` + `OutPacket`
   on the unconditional path because the inbound forwarder + (in
   commit 3) the iOS capture pipeline both reference them.

Also fix a stray duplicate `#[cfg(not(target_os = "ios"))]`
attribute that landed on line 18 in commit 2.

Build verify (Linux host): cargo check -p chanora_audio clean
in 0.53s. iOS-side check pending on Mac.

Build counter bumped 60 -> 61 — the About dialog will display
v1.0.0-rc.8+61 so the user can confirm the build under test
matches this commit.
2026-05-17 00:53:39 +08:00
EdisonJwa 97a6ba6b55 feat(ui): show build number in About dialog (v1.0.0-rc.8+<build>)
Add package_info_plus 8.3.1 (Flutter Community Plus, BSD-3, ~3M
downloads) and resolve the displayed version string from the
platform manifest at app init. The string shown in the About
dialog is now formatted as 'v<version>+<build>' (e.g.
'v1.0.0-rc.8+60') where both halves come from the SAME
pubspec.yaml 'version:' field that Flutter uses to populate iOS's
CFBundleShortVersionString + CFBundleVersion and Android's
versionName + versionCode.

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

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

The consumer site at the About dialog (l10n.aboutVersion(_kAppVersion))
is unchanged — the variable still resolves to a String. flutter
analyze: clean.
2026-05-17 00:49:05 +08:00
EdisonJwa 3b1724e970 feat(audio,ios): wire IosVoiceUnit into AudioEngine, cfg-gate cpal away on iOS (commit 2/5)
Split AudioEngine::start_with_gate into two backends:

* start_with_gate_cpal  — non-iOS path, the existing cpal + (SDL on
                         Linux) flow, renamed verbatim, no
                         behavioural change.
* start_with_gate_ios   — iOS path, constructs a single
                         IosVoiceUnit (VoiceProcessingIO via
                         coreaudio-rs) for combined mic + speaker.
                         Spawns the same inbound forwarder task
                         that pumps Opus packets into AudioHandler.

The public entry point start_with_gate dispatches at the top via
cfg(target_os = "ios") so callers stay backend-agnostic.

Struct field changes:
* _input_stream  : cfg-gated to not(ios)
* _output_stream : cfg-gated to not(ios), keeps the
                   Linux=SdlOutput / else=cpal::Stream split
* _ios_voice_unit: new field, cfg-gated to ios, owns the VPIO
                   AudioUnit for the engine's lifetime.

Module-level cfg-gating:
* All cpal-only helpers (try_open_capture, build_input_stream,
  build_output_stream, CaptureState + impl, ToF32 / FromF32 traits
  and impls, PlaybackResampleState) are now wrapped with
  #[cfg(not(target_os = "ios"))]. Same for the audiopus
  encoder + cpal trait imports — iOS doesn't pull libopus into the
  engine yet (commit 3 will, once the VPIO input callback wires
  into CaptureState).

Behaviour on iOS for THIS commit:
* AudioEngine starts cleanly, IosVoiceUnit::start succeeds (VPIO
  unit allocates + initialises + starts).
* Mic capture is dropped (the input callback is a no-op stub).
* Output emits silence (the render callback fills the buffer with
  zeros).
* Inbound forwarder still runs and pushes Opus packets into
  AudioHandler — they accumulate in the jitter buffer but no
  fill_buffer drain happens (commit 4 fixes that), so the buffer
  will grow up to MAX_BUFFER_TIME (~0.5 s) and then tsclientlib
  starts dropping the oldest frames. This is fine for now — the
  point of this commit is verifying the AudioUnit constructs +
  starts cleanly on the device. Audible silence is the expected
  state until commits 3/4 land.

Build verify (Linux host): cargo check -p chanora_audio clean in
0.53s. iOS-side compile happens on the Mac via the Xcode build
the user will trigger next.
2026-05-17 00:45:50 +08:00
EdisonJwa af686ca7a6 feat(audio,ios): add coreaudio-rs dep + IosVoiceUnit skeleton (commit 1/5)
Skeleton scaffolding for the iOS VoiceProcessingIO backend that
will replace cpal on iOS. This commit lands the dependency + the
module + a constructable AudioUnit that emits silence and drops
input; nothing in engine.rs is wired up yet (that is commit 2).

Compilation contract for this commit:
* Linux / Windows / macOS / Android builds unaffected (the new
  module is target_os='ios' gated, the new dep is in
  '[target."cfg(target_os = \"ios\")"]').
* iOS build pulls in coreaudio-rs 0.14, constructs a VPIO unit,
  pins stream format to 48 kHz Int16 mono on both buses, installs
  no-op input + silence-emitting render callbacks, initializes,
  and starts. No audio is actually moved until commits 3/4.

Why VPIO and not RemoteIO via cpal: cpal's iOS backend opens
RemoteIO with no control over stream format / buffer size /
channels and produces a mono-only output element that stays bound
to the route present at construction time. End-user symptom on
iPhone 16 Pro iOS 18.7.8: tapping Speaker in the picker flips
AVAudioSession.currentRoute.outputs to Speaker (confirmed in our
diagnostic logs from commit da631a2) but audio keeps coming out
the receiver because the AudioUnit's output binding is stale.

Every production iOS VoIP client (Mumble iOS, Linphone /
mediastreamer2, Signal-iOS, Jitsi Meet iOS, the WebRTC reference
impl) avoids RemoteIO and uses VoiceProcessingIO instead. VPIO is
Apple's recommended voice unit; it ships hardware AEC + AGC + NS
and re-binds the physical transducer correctly on route changes
because it IS the canonical voice unit on iOS — FaceTime's audio
path runs through it.

coreaudio-rs 0.14 (RustAudio org, 8.6M downloads, same maintainers
as cpal) gives us a safe wrapper around the AudioUnit C API on
iOS. Uses objc2-* crates underneath so links cleanly into iOS
builds. ios_voice_unit.rs sits next to sdl_output.rs as the iOS
sibling of the Linux SDL2 output path.

Build verify (Linux host): `cargo check -p chanora_audio`
finished clean in 16.09s. iOS build verification happens in
commit 2 when the module is exercised; for this commit the module
compiles in isolation but is dead code on iOS too (no caller).
2026-05-17 00:42:47 +08:00
EdisonJwa 6a4dbad60e fix(ios,audio): use AVAudioSession mode .default + correct mono downmix
Two changes that together address the user-reported 'speaker selector
not working' AND 'audio quality bad' symptoms on iPhone:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default

   .voiceChat binds the underlying AudioUnit's output element to a
   SINGLE physical transducer (the receiver/earpiece) at session-
   configure time. overrideOutputAudioPort updates AVAudioSession's
   route metadata so currentRoute.outputs reports Speaker, but the
   AudioUnit's output binding is stale and audio keeps routing to
   the original transducer. Net: tapping Speaker in the picker
   flipped the route in our log but produced no audible change.

   .voiceChat also enables iOS's telephony processing chain (forced
   mono output, aggressive AGC, heavy noise gating) which explains
   the 'garbled / watery / metallic' quality complaints.

   .default mode uses iOS's standard audio graph: stereo output, no
   AGC, no telephony post-processing, AudioUnit re-binds live when
   the route changes. Same mode Music.app and most non-telephony
   apps use. Trade-off: we lose iOS hardware AEC. If users report
   speakerphone echo we'll add software AEC (DEC-030).

   Category options unchanged \u2014 .allowBluetoothHFP +
   .allowBluetoothA2DP still permit BT headsets in both directions.

2. engine.rs::build_output_stream: mono device downmix fix

   The mixing path at dev_channels==1 previously wrote only the L
   channel of AudioHandler's stereo output into the single mono
   device channel and discarded R entirely. Anything panned right
   in the stereo voice mix was silently lost \u2014 on .voiceChat
   speakerphone (forced mono device) this manifested as quiet
   remote speakers being inaudible. Fix: when dev_channels==1,
   output = (L + R) * 0.5 instead of just L. The dev_channels>=2
   branch is unchanged.

   With change #1 iOS will typically expose stereo so this branch
   is rarely hit, but the fix is correct for any genuinely-mono
   sink (some BT car-audio profiles, USB mono headsets).
2026-05-17 00:18:48 +08:00
EdisonJwa 2f6cdd3ad4 Revert "diag(ios,audio): log AVAudioSession state + route changes around picker overrides"
This reverts commit da631a2bef.
2026-05-17 00:17:10 +08:00
EdisonJwa da631a2bef diag(ios,audio): log AVAudioSession state + route changes around picker overrides
Add structured NSLog instrumentation to AppDelegate.swift and debugPrint
chains in voice_compact.dart::_AudioOutputPickerSheetState so we can
correlate user picker taps with what iOS actually does to the route.

Three diagnostic streams:

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

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

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

Root cause:

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

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

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

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

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

Fix:

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

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

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

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

Root cause:

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

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

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

Fix:

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

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

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

flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
2026-05-16 21:32:41 +08:00
EdisonJwa 54ec8dd5a8 feat(audio): tune Opus encoder for VoIP (bitrate 32k, complexity 10, inband FEC, 5% PLC)
User report: 'sound heard are too poor' on iPhone iOS \u2014 garbled/
robotic + stutters/dropouts.

The Opus encoder ran with audiopus defaults: 'auto' bitrate that
drops to ~6 kbps during silence (sounds watery on speech resume),
inband FEC disabled (single packet loss = silent gap), no packet-
loss percentage hint (encoder can't budget bits for redundancy).

On lossy mobile networks (cell, WiFi roaming) this combination
sounds noticeably worse than the same Opus stream from a desktop
client. Garbled = silence-bitrate transitions; stutters = packet
loss without FEC.

Fix: tune the encoder once at construction with values derived
from RFC 6716 \u00a77.1 (Opus VoIP recommendations), Discord's voice
client tuning, and the Mumble defaults.

  * set_bitrate(32_000)        \u2014 sweet spot for mono speech. Below
                                  24 kbps starts to sound watery;
                                  above 64 kbps wastes bandwidth.
                                  Discord uses 64 kbps; Mumble
                                  defaults to 40 kbps; we pick
                                  32 kbps as a conservative VoIP
                                  value that survives ~100 kbps
                                  uplinks comfortably.
  * set_complexity(10)         \u2014 max quality. The CPU cost on a
                                  modern iPhone (A14+) or any
                                  desktop is negligible (~0.5 % of
                                  a single core for 48 kHz mono).
  * set_inband_fec(true)       \u2014 Opus inserts a low-bitrate copy
                                  of the previous frame inside the
                                  current packet so single-packet
                                  loss can be reconstructed from
                                  the next packet. Essential on
                                  lossy mobile. The decoder side
                                  (tsclientlib's AudioHandler)
                                  auto-handles FEC frames; no
                                  receiver-side change needed.
  * set_packet_loss_perc(5)    \u2014 tells the encoder to budget
                                  bits for 5 % expected loss.
                                  Higher values trade audio
                                  quality for resilience.

Each setter is wrapped in a soft-fail: if an exotic libopus build
rejects one of these, we log + continue with the still-functional
encoder rather than aborting the audio engine. info!-log a one-
liner per-engine-start summarising the tuned values so a future
diagnostic export can correlate audio reports with the active
configuration.

Application::Voip mode was already set (engine.rs:630, unchanged);
the new calls layer on top of that mode's defaults.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

New widgets in voice_compact.dart:

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

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

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

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

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

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

Root cause analysis:

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

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

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

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

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

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

  TextField(
    ...
    onTapOutside: _onTapOutside,
  )

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

Why this is strictly better:

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

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

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

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

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

Root cause:

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

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

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

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

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

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

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

Root cause of the lag:

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

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

Fix: remove _kickFocus entirely. Rely on:

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

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

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

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

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

1. Audio output displaying as Unknown on iOS

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

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

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

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

Modal sheet is now a single-screen control panel:

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

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

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

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

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

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

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

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

Awaiting iPhone retest to confirm all three fixes.
2026-05-16 18:44:48 +08:00
EdisonJwa 79f83604da fix(ui,ios): apply community workaround for flutter/flutter#181474 (iOS 26 keyboard stale-focus)
User reported: 'input field still need to click twice' on iPhone +
'a bit lag after keyboard pop up'. The previous Listener-based
approach (020be77 \u2192 23bd1c7) actively made both symptoms worse.

Root cause is a confirmed open Flutter framework bug:

  flutter/flutter#181474 \u2014 [iPadOS] Keyboard is dismissed, but the
  TextField keeps focus, causing subsequent taps not to trigger
  keyboard presentation.
  Open, P2, triaged-text-input, platform-ios, e: OS-version specific.
  Reported on iPadOS 26.2 + Flutter 3.38.1 in Jan 2026 by
  Crazymuyang.

Reproduction matches our symptom exactly: iOS 26 dismisses the soft
keyboard (e.g. on tap-outside, or in some fresh-launch states), but
the EditableText's FocusNode keeps hasFocus = true. Because the
node is already focused, the next user tap is a no-op from the
focus system's perspective, so the platform TextInput channel is
never re-opened and iOS keeps the soft keyboard hidden until a
second tap finally triggers an explicit re-focus path.

Why our previous attempts failed:

  * 020be77 wrapped each TextField in a Listener that called
    requestFocus pre-arena. That's racing the wrong layer \u2014 it
    doesn't help when the bug is 'node is already focused, so
    requestFocus is a no-op'.

  * 23bd1c7 added SystemChannels.textInput.invokeMethod('TextInput.show')
    to the same Listener. This forced the keyboard up but raced
    EditableText's own attach path, producing the post-attach
    typing lag the user reported.

Fix: the community-recommended workaround in the issue thread \u2014
on every TextField tap, **unfocus first, then re-request focus on
the next microtask**. This forces a real false\u2192true focus-change
transition that re-opens TextInput on the first tap. No platform
channel races, no gesture-arena fighting, no Listener wrappers.

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

  TextField(
    onTap: () => _kickFocus(_hostFocus),
    ...
  )

Wired into all three connect-form fields (host / nickname /
password). Removed the now-redundant _focusOnTap Listener helper.

No-op on hosts where #181474 doesn't reproduce \u2014 the unfocus call
is a no-op when the node isn't focused, and the microtask
requestFocus is what TextField would have done anyway via its own
TapGestureRecognizer.

flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.6 s, Runner.app 30.2 MB.
2026-05-16 18:31:48 +08:00
EdisonJwa 23bd1c7930 fix(ui,ios): force TextInput.show on first tap (keyboard-up-on-first-tap)
User reported: 'input field still need to click twice' on iPhone.
The 020be77 Listener + requestFocus() approach was insufficient.

Root cause analysis:

  * Flutter's EditableText opens the platform TextInput method
    channel (which is what slides the iOS soft keyboard up) only
    after a TapGestureRecognizer wins the gesture arena.

  * Our outer Listener calls node.requestFocus() pre-arena. That
    flags the FocusNode as focused in Flutter's focus tree, but
    does NOT open the TextInput channel \u2014 so iOS keeps the OS
    keyboard hidden until EditableText's own tap recognizer wins
    on a second tap.

  * requestFocus on its own is therefore a no-op for the user
    visually: the cursor + caret appear briefly but the keyboard
    stays down.

Fix: in the Listener.onPointerDown handler, additionally invoke
'TextInput.show' on SystemChannels.textInput. This is the same
private platform RPC EditableText calls internally on attach;
forcing it ourselves slides the keyboard up regardless of arena
state.

Belt-and-braces with the existing requestFocus() guarantees
keyboard-on-first-tap on iPhone / iPad and is harmless on:
  * Android (the platform ignores the redundant show call when
    the keyboard is already up),
  * Linux / Windows / macOS desktop (no soft keyboard exists; the
    method channel handler returns success without doing
    anything).

No new imports needed \u2014 SystemChannels is already in
package:flutter/services.dart (imported for FilteringTextInputFormatter).

flutter build ios --release --no-codesign: 20.7 s, Runner.app
30.2 MB.
2026-05-16 18:23:57 +08:00
EdisonJwa b8e9c8549e feat(ui,ios,android): audio output route picker + collapse voice controls into single modal
Two user-reported issues addressed:

  1. 'on supported devices such as iphone or android user should be
     able to select audio device such as speaker or airpods or phone'
  2. 'the bottom folder is duplicated with the app bar settings'

Issue 1 \u2014 audio output route picker:

Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:

  * iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
    Control Center's audio chooser. Lists Speaker / iPhone receiver /
    AirPods / connected Bluetooth devices / AirPlay / CarPlay.
    System manages the device list; we don't have to track route
    changes manually.

  * Android (post-rc.8 when we wire the platform): Material Design 3
    dialog backed by AudioManager.setCommunicationDevice() with
    SCO Bluetooth + USB headsets filtered for VoIP.

The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit 0466000.

The new _AudioOutputTile widget in voice_compact.dart subscribes to
AudioRouter.currentDeviceStream so the row label + icon auto-update
when the user plugs in headphones, connects AirPods, etc. \u2014 no
manual KVO observation needed.

Tile is mobile-only (Platform.isIOS || Platform.isAndroid). Desktop
hosts continue to use the system mixer; the tile is hidden.

Plugin caveat: the published enum AudioSourceType has no .usb
variant (despite README mentioning USB support). We map only the
seven real enum cases: builtinSpeaker / builtinReceiver / bluetooth /
wiredHeadset / carAudio / airplay / unknown.

Issue 2 \u2014 collapse voice controls into the single modal sheet:

The AppBar gear icon (Icons.tune) that opened VoiceSettingsDialog
was removed. It duplicated the configuration entry point that the
status chip \u2192 modal-sheet path already provides, and the user found
that duplication confusing on a phone-narrow screen where AppBar
real estate is precious.

The voice modal sheet (showVoiceDetailsSheet) is now the **single**
voice-controls surface on mobile, with layout (top to bottom):

  1. Audio output route picker tile (iOS / Android only) \u2014 new.
  2. Mode + bind / release-tail recap (display only).
  3. 'Adjust mode & release tail' OutlinedButton that closes the
     sheet and opens the same VoiceSettingsDialog the gear icon
     used to open. One config form, not two.
  4. Mic level meter.
  5. TX / RX frame counts + mic state.
  6. PTT capability badge (desktop only).

Sheet title renamed from 'Voice settings' (which collided with the
gear-icon tooltip) to 'Voice'. New l10n keys: voiceSheetTitle,
voiceAdjustSettings, audioOutputLabel, audioRoute{Speaker,Receiver,
Bluetooth,WiredHeadset,CarAudio,Airplay,Unknown}. en + zh translated.

Build: flutter build ios --release --no-codesign clean, 50.5 s,
Runner.app 30.2 MB (+200 KB from audio_router). flutter analyze
clean (6 pre-existing Radio.groupValue deprecation infos in
voice_settings.dart, unchanged).
2026-05-16 18:18:43 +08:00
EdisonJwa fa94b9438f feat(ui): URL-shaped keyboard + lowercase enforcement for server host
The server-host TextField on the connect form accepts a hostname or
hostname:port pair (e.g. kr.teamspeak.app:9987). Two improvements:

  1. keyboardType: TextInputType.url surfaces '.', '/', ':' on the
     primary on-screen keyboard plane so the user does not have to
     switch to the symbols pane mid-address. Matches iOS Safari's
     URL bar.

  2. textCapitalization.none + autocorrect/enableSuggestions=false
     prevents iOS from auto-capitalising the first letter or
     'correcting' 'kr.teamspeak.app' to something else.

  3. inputFormatters belt-and-braces:
       * deny whitespace (handles tab-indented paste)
       * lowercase pipeline (handles uppercase paste)

  4. Visual affordances: prefix dns icon + 'host[:port]' hint.

Nickname field intentionally unchanged \u2014 may contain unicode,
mixed case, spaces.

flutter build ios --release --no-codesign: 30.7 s, Runner.app
30.0 MB. flutter analyze clean (6 pre-existing deprecation
warnings on Radio.groupValue/onChanged).
2026-05-16 18:05:41 +08:00
EdisonJwa 4ee2b3850a fix(ios): rename allowBluetooth -> allowBluetoothHFP (iOS 26 SDK)
Xcode warning on iOS SDK 26+:
  'allowBluetooth' was deprecated in iOS 8.0: renamed to
  'AVAudioSession.CategoryOptions.allowBluetoothHFP'

The flag was renamed in iOS 8 (a decade ago) but the old name has
been kept as a soft-deprecated alias. iOS 26 SDK finally emits the
warning, and -Werror builds would fail on it. Same semantics:
permit HFP-profile Bluetooth headsets as input + output. Kept
.allowBluetoothA2DP alongside for higher-quality output-only A2DP
devices.

flutter build ios --release --no-codesign: 10.7 s, Runner.app
30.0 MB.
2026-05-16 17:56:25 +08:00
EdisonJwa 0466000733 fix(ios): defer AVAudioSession.setActive(true) to didBecomeActive
From iPhone log:
  chanora_flutter: AVAudioSession setup failed:
    Error Domain=NSOSStatusErrorDomain Code=561017449
    'Session activation failed'

Error code 561017449 = AVAudioSessionErrorCodeCannotStartPlaying
(ASCII '!cat' big-endian). iOS 17+ refuses setActive(true) calls
made before the app's scene is foregrounded: the audio policy
server denies the activation because the app is not yet considered
the foreground priority owner. didFinishLaunchingWithOptions runs
BEFORE the scene becomes .active, so synchronous activation there
hits this race on cold launch.

Symptom flow:
  1. App cold-launch -> AppDelegate.didFinishLaunching fires
  2. setActive(true) -> Error 561017449
  3. Audio session is left inactive
  4. cpal's later attempts to open RemoteIO see an inactive
     session and reject with StreamConfigNotSupported
  5. voice_join fails at ensure_audio_running
  6. user sees the audio failure manifested as missing mute /
     continuous / PTT buttons (now fixed in f1f81a3 to be
     lenient; this commit also unblocks the underlying audio).

Fix: split the AVAudioSession configuration into two phases:
  * setCategory at didFinishLaunching (always safe).
  * setActive(true) deferred to UIApplication.didBecomeActive    Notification, which fires after the cold-launch settle and
    on every resume-from-background. Repeated setActive while
    already-active is a no-op per docs.

This is the canonical iOS voice-app pattern (Discord, Zoom,
FaceTime, Flutter's  package all follow it). Documented
in commit body comments.

flutter build ios --release --no-codesign: 13.2 s, Runner.app
30.0 MB.
2026-05-16 17:53:49 +08:00
EdisonJwa 020be77faf fix(ui,ios): AppBar OVERFLOWED-BY strip + first-tap TextField via Listener
Two distinct fixes prompted by user reports from the iPhone build:

1. 'strange text on Chanora (RFLOWED BY)' \u2014 the Flutter debug
   overlay's 'OVERFLOWED BY N PIXELS' strip was appearing next to
   the AppBar title because the title Row ('Chanora' + channel
   pill) plus 5-6 trailing IconButton actions exceeded a typical
   iPhone AppBar width. User saw the strip clipped to '...RFLOWED
   BY...' since only its end fit on screen.

   _AppBarTitle now drops the 'Chanora' label on narrow widths
   (<840 dp). Title shows only the channel pill when in voice
   channel; the user already knows they're in Chanora because
   they just opened it. Wide widths (tablet/desktop, >= 840 dp)
   keep the full 'Chanora \u00b7 #channel-pill' title because there's
   room. Eliminates the overflow.

   Note: the OVERFLOWED-BY strip only renders in debug builds
   anyway; release builds suppress the overlay. But the
   underlying Row overflow was a real layout bug worth fixing.

2. First-tap TextField still failed on iPhone after the earlier
   FocusNode + TextField.onTap fix. Root cause: TextField.onTap
   fires AFTER the gesture-arena resolves, so if the enclosing
   SingleChildScrollView wins the arena (which it does on iOS
   for the very first tap), the focus request never fires.

   Wrap each connect-form TextField in a Listener with
   HitTestBehavior.translucent and onPointerDown: requestFocus.
   Listener fires synchronously on PointerDownEvent BEFORE arena
   resolution, so even if the scrollable would have won the arena
   we have already grabbed focus. Translucent means the pointer
   ALSO propagates down to the TextField so its normal touch
   handling still runs (text selection / cursor placement).
   _focusOnTap helper added; wraps all three TextFields
   (host, nick, password).

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
flutter build ios --release --no-codesign: 18.4 s, Runner.app
30.0 MB.
2026-05-16 17:42:05 +08:00
EdisonJwa a93109ac37 fix(ios): rebuild chanora_bridge.framework on every Xcode build, not just pod install
The podspec's prepare_command only fires on `pod install`. Once a
framework was generated, Rust source changes were silently ignored
because Xcode kept re-bundling the stale framework into Runner.app.
Manifested today as: ran `cargo build --release --target
aarch64-apple-ios` to pick up the lenient voice_join fix, ran
`flutter build ios`, but Runner.app/Frameworks/chanora_bridge.
framework/chanora_bridge was still the framework from the previous
pod install (16:10) not the just-built 18:30 dylib.

Add an explicit `script_phase` to the podspec that re-runs:
  1. cargo build --release --target aarch64-apple-ios -p chanora_bridge
  2. cp dylib into Frameworks/chanora_bridge.framework/chanora_bridge
  3. install_name_tool -id @rpath/...

on every Xcode 'Build', not just on pod install. The script short-
circuits when the framework's binary mtime is newer than the cargo
output (fast no-op on incremental builds where Rust didn't change).

Side effect: every Xcode build now invokes cargo, which can take
~5 s on a warm cache and ~1 min cold. This is the right trade-off
because the previous behavior silently shipped stale Rust code.
2026-05-16 17:31:43 +08:00
EdisonJwa f1f81a3d7e fix(core,bridge,ios): voice_join survives audio-engine failure; iOS log file
User report from iPhone: 'mute / continuous / PTT buttons missing'
with NO error popup. Root cause: voice_join's audio-engine startup
was failing silently, and the failure propagated out as a hard
error \u2014 which means SessionEvent::VoiceState(true) was never sent
to Dart even though the server-side channel move had already
succeeded. Dart's _inChannel stayed false; every control gated on
_inChannel disappeared while the channel tree continued to show
the user as joined.

This commit makes voice_join lenient on audio-engine failures so
the UI state matches the server-side reality, and also gives iOS
a writable log file so diagnostics from device builds are
recoverable for the first time.

core/chanora_core/src/lib.rs::voice_join
  * ensure_audio_running's error is now logged + emitted as
    SessionEvent::AudioStopped, but does NOT abort voice_join.
    The server move at step 1 already succeeded; failing the
    Dart-visible promise here would leave the UI in a phantom
    'in-channel visually but no controls' state. After this
    commit:
      - mic / headset / settings appear in the AppBar
      - PTT button appears at the bottom
      - status chip shows live audio state ('Mic on/off')
      - if audio actually failed (mic permission denied,
        no input device, CoreAudio rejecting stream config)
        the user can retry by switching modes / channels;
        BridgeEvent::AudioStopped wires _audioStarted=false
        in Dart so audio-stats poll is honest about the
        engine state.

crates/chanora_bridge/src/api.rs::log_file_path
  * iOS now writes the log to /home/milkice/Documents/chanora.log
    (Documents is the standard user-visible iOS sandbox dir).
  * Android remains None pending the bridge JNI init wiring
    a writable path (P1 follow-up).

apps/chanora_flutter/ios/Runner/Info.plist
  * Adds UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
    so the Documents directory shows up under 'On My iPhone \u2192
    Chanora' in the Files.app. The user can now copy chanora.log
    out for support without needing Xcode \u2192 Devices and
    Simulators \u2192 Download Container.

Workspace tests: 78/0/1 unchanged.
flutter build ios --release --no-codesign: 22.7 s clean
(Runner.app 29.9 MB).
2026-05-16 17:16:17 +08:00
EdisonJwa 3b08ea9d32 feat(ui,mobile): Plan E voice UI -- status chip + bottom-anchored PTT + AppBar mutes
Restructure the narrow / mobile body layout around three principles
(Hoober thumb-zone research validated, Material 3 components):

  1. Channel tree gets ~85% of the screen height.
  2. Live voice state is always visible in a 2-line status chip
     above the PTT button.
  3. PTT button is wide, bottom-anchored (thumb-natural lower zone).
  4. Frequent toggles (mic mute, headset mute, voice settings) live
     in the AppBar so they don't compete with the channel tree.
  5. Non-essential live readouts (level meter, TX/RX, capability
     badge) live in a modal sheet opened by tapping the status chip
     -- progressive disclosure.

apps/chanora_flutter/lib/widgets/voice_compact.dart  (new file)
  * VoiceStatusChip: 2-line live readout. Line 1 = mode + bind hint;
    Line 2 = release-tail + 'Mic on/off'. Tap opens
    showVoiceDetailsSheet.
  * VoicePttButton: 56 dp wide bottom-anchored Push to Talk button.
    Same touch-and-hold gestures as the previous _PttHoldButton.
  * showVoiceDetailsSheet: modal bottom sheet with mode recap +
    bind/tail hint + level meter + TX/RX counts + PTT capability
    badge (desktop-only).

apps/chanora_flutter/lib/main.dart
  * AppBar gains mic-mute, headset-mute, voice-settings icons when
    in voice channel AND MediaQuery width < 840 dp (mobile only).
    Wide mode keeps these controls inside the existing VoiceBar
    widget unchanged.
  * AppBar title becomes a Row of 'app name + channel chip' when
    in voice channel.
  * Narrow-mode body restructured: Expanded(channelTree) +
    VoiceStatusChip + VoicePttButton (latter only when in voice
    channel AND PTT mode). The old narrow-mode VoiceBar is gone;
    wide-mode VoiceBar is unchanged.
  * New _onOpenVoiceDetailsSheet handler bridges the chip-tap to
    showVoiceDetailsSheet.
  * New top-level helper _isTouchOnlyPttHost mirrors the helpers
    in widgets/voice_bar.dart and widgets/voice_settings.dart so
    the AppBar + narrow-mode chip can branch consistently.

apps/chanora_flutter/lib/l10n/app_en.arb
apps/chanora_flutter/lib/l10n/app_zh.arb
apps/chanora_flutter/lib/l10n/generated/*  (regenerated)
  * New string voicePttHoldHint = 'Hold the button' / '按住按钮'.
    Surfaced in line 1 of VoiceStatusChip on touch-only hosts and
    in the modal sheet's PTT line where the desktop equivalent
    would name a bound key.

Wide-mode (>= 840 dp) layout intentionally unchanged so the
signed-off rc.8 desktop verification still applies.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Local cargo + flutter analyze pass; Mac was offline at commit
time so iOS device build verification is pending the next sync.
2026-05-16 16:57:08 +08:00
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 6d094f3dbe docs(verification,index): iPad p0 acceptance checklist + 0.9.9 index row
New controlled document docs/verification/ipad-p0-acceptance.md
extends iOS P0 coverage to iPad. The build artefact is identical
to iPhone — `TARGETED_DEVICE_FAMILY = "1,2"` in
ios/Runner.xcodeproj/project.pbxproj is the Universal family, so
the same Runner.app installs on iPad with the same personal-team
provisioning profile.

15-row checklist mirrors ios-p0-acceptance.md TC-1..TC-12 and adds
three iPad-specific rows:

  TC-13 wide-mode landscape layout — iPad in landscape is well
        above the 840 dp LayoutBuilder breakpoint shipped in
        apps/chanora_flutter/lib/main.dart, so the connected view
        splits into a 320 dp left column (banner + Voice Bar) plus
        an expanding channel tree. Portrait rotation collapses
        back to the stacked iPhone layout. Long channel-name pills
        still ellipsize per the earlier voice_bar.dart fix.

  TC-14 Split View / Slide Over no-crash — Apple iPad multitasking
        is intentionally unsupported in P0. UIApplicationSupports\
        MultipleScenes stays false. This row asserts the app does
        not crash when iPadOS tries to host it in Split View; the
        actual multi-scene wiring is P1.

  TC-15 AirPlay 2 audio route — verifies AVAudioSession routing
        honours an AirPlay 2 destination picked via Control
        Center, and routes back cleanly when iPad is reselected.

docs/governance/document-index.md
  Bumped to 0.9.9 with the change-history row noting the iPad
  acceptance doc. No spec items added; DEC-025 was originally
  iPhone-only for the mobile target and this row formally
  extends P0 coverage to iPad within the same iOS toolchain.

python3 tools/validate_docs.py: clean (pre-existing 35-filename
[FAIL] retained, unchanged).
2026-05-16 14:41:33 +08: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 41d5a4b91b docs(verification,index): macOS + iOS p0 acceptance checklists + 0.9.8 index row
New controlled documents:

  * docs/verification/macos-p0-acceptance.md
    15-row human-must checklist for Apple Silicon macOS P0 sign-off.
    Targets the SDD-085 CGEventTap backend (now fully live) +
    SDD-094..097 audio lifecycle. Auto-test rows pre-filled from the
    M1 Mac verification pass: chanora_audio 34 / 0 / 0 (Linux: 32;
    macOS delta is the keymap + Send-bound + descriptor-builder
    tests). Pre-flight covers the manual framework-wrap +
    install_name_tool + ad-hoc codesign step via the new
    tools/macos-postbuild.sh. TC-3 walks the Input Monitoring grant
    + descriptor watch transition timing (~1.5 s).

  * docs/verification/ios-p0-acceptance.md
    12-row human-must checklist for iOS P0 sign-off on a physical
    iPhone via the developer's free Apple Personal Team. TC-3
    documents the Focused-only PTT capability iOS gives us (no
    global event tap analogue exists). TC-8 covers UIBackgroundModes
    = audio. TC-9 covers AVAudioSession routing — phone-call
    interruption, AirPods route, etc.

docs/governance/document-index.md
  Bumped to 0.9.8 with a change-history row covering both new
  acceptance documents. No spec items added; the existing SDD-085
  (macOS) and SDD-094..097 (audio lifecycle) are what these
  documents sign off.

python3 tools/validate_docs.py: clean (pre-existing 35-filename
[FAIL] retained, unchanged).
2026-05-16 13:50:57 +08: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 383b707e7c feat(audio,macos): live CGEventTap PTT capture (SDD-085)
Replace the macOS PTT backend's worker-thread stub (which had just
slept) with a full CGEventTap implementation:

  * extern "C" bindings to CGEventTapCreate, CGEventGetIntegerValueField,
    CGEventTapEnable, CFMachPortCreateRunLoopSource, CFRunLoopGetCurrent,
    CFRunLoopAddSource/RemoveSource, CFRunLoopRun/Stop, CFRelease, plus
    the kCFRunLoopCommonModes static.
  * tap_callback: C-ABI extern fn that reads bound keycode /
    mouse-button from atomics, matches the incoming event, and toggles
    the AudioTransmitGate. Returns the event unchanged (listen-only
    tap, no event modification). Privacy-safe: never logs raw key
    codes or button numbers (DEC-027).
  * Event mask covers kCGEventKeyDown, kCGEventKeyUp,
    kCGEventOtherMouseDown, kCGEventOtherMouseUp; also handles the
    kCGEventTapDisabledBy{Timeout,UserInput} notifications by logging
    a degraded-mode warning.
  * Worker thread captures the CFRunLoopRef via a Send-marked
    RunLoopHandle newtype so stop() can call CFRunLoopStop from the
    audio engine thread.
  * Box<TapState> is leaked into the worker via a Send-marked
    TapStatePtr newtype; reclaimed on worker exit so the gate's Arc
    refcount stays correct.
  * Flutter logical-key labels are mapped to Carbon virtual keycodes
    via label_to_macos_keycode (covers letters, digits, function keys,
    navigation, common punctuation). Mouse-side-button labels resolve
    via label_to_macos_mouse_button (3 = Mouse4, 4 = Mouse5).
  * refresh_bound_atomics() rebuilds bound_keycode + bound_mouse_button
    on start() and rebind() so the tap callback sees the new binding
    without re-arming the tap.

Tests: chanora_audio 34 / 0 / 0 on macOS (was 28 before this commit).
Added: keymap_letters, keymap_function_keys, keymap_navigation,
keymap_unknown_returns_none, mouse_button_map, runloop_handle_is_send.

Verified the live IOHIDCheckAccess returns Undetermined (Unknown=2) on
a fresh M1 box where Input Monitoring has never been requested; the
1.5 s permission-watcher re-publishes the descriptor on user grant
or revoke without restart.

DEC-025: macOS desktop officially in scope. SAD-073: two-level PTT
ladder. SRS-198: honest capability advertising. DEC-027: privacy.
2026-05-16 13:50:28 +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 ade50488d9 feat(audio,macos): live IOHIDCheckAccess for Input Monitoring permission
Replaces the macOS PTT backend's query_permission() stub (which had
returned Undetermined unconditionally) with a real IOKit call:

  extern "C" { fn IOHIDCheckAccess(request_type: u32) -> u32; }
  IOHIDCheckAccess(kIOHIDRequestTypeListenEvent = 1)

Returns Granted (0), Denied (1), or Unknown (2). The existing 1.5 s
re-query worker now drives real descriptor transitions when the user
grants or revokes Input Monitoring in System Settings: the watch
sender republishes the descriptor, ChanoraSession forwards
BridgeEvent::PttCapability, and the Flutter capability badge updates
within ~1.5 s without an app restart.

Verified live on the M1 Mac:
  rustc /tmp/check_perm.rs && ./check_perm
  IOHIDCheckAccess(ListenEvent) = 2 (Unknown)
This is the expected initial state on a fresh box where Chanora has
not yet attempted CGEventTapCreate; once the next commit lands the
event-tap worker, the macOS Input Monitoring prompt will fire on
first audio start and the value transitions to Granted/Denied.

Tests: chanora_audio 28 / 0 / 0 on macOS (Linux had 32; the 4-test
delta is the Linux-only portal probe tests). The existing 7 macOS
backend unit tests still cover the descriptor builder + state
machine purely; they don't exercise the live IOKit call (which
would need a TCC-aware test harness).

SDD-085 reference: macOS Event Tap backend / L2 / L3 capability;
SRS-198 honest capability advertising.
2026-05-16 14:09:47 +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 cd402f164a docs(verification,index): linux p0 acceptance checklist + 0.9.7 index row
New controlled document docs/verification/linux-p0-acceptance.md mirrors
docs/verification/windows-p0-acceptance.md with 15 TC rows tuned for the
GNOME-on-Wayland target environment (DEC-025). Pre-flight calibrated to
the Arch verification host (100.74.219.114): pacman queries, path
prefixes, xdg-desktop-portal-gnome version notes. Auto-test sign-off
filled with the headless verification pass run over SSH:

  cargo check --workspace --release           clean (30.99 s)
  cargo test --workspace --lib                78 / 0 / 1
  cargo test ... linux_portal_smoke -- --ignored
    -> 1 / 0  (GlobalShortcuts portal reachable, version = 1)
  cargo test ... ptt_privacy                  1 / 0  (DEC-027 holds)

The 15 GUI rows are marked pending physical-console pass. SDD-086 portal
flow is what this doc signs off; SDD-081/094..097 are referenced.

Document index bumped to 0.9.7 with the change-history row covering the
new acceptance doc. No spec items added; pre-existing 35-filename FAIL
in validate_docs.py retained.
2026-05-16 12:15:24 +08: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 d9c330c23b feat(audio,linux): output via SDL2; cpal stays on Windows/macOS
User reported persistent crackling/popping from peer audio on Linux even
after fixing the 48k->device-rate resampler boundary discontinuities,
clamping pre-Opus-encode peaks, and pre-allocating the playback scratch
buffer. Logs confirmed cpal opened raw ALSA at 44.1k native, no callback
budget violations, no underrun warnings -- yet the audio was still poor.

Root cause: cpal on Linux opens raw ALSA's 'default' PCM. On modern
PipeWire / pipewire-alsa boxes that virtual device routes through ALSA's
dmix + plug layers, whose default resampler is nearest-neighbour. cpal
also picks a small default period size (~256 frames / 5.8 ms) leaving no
headroom for kernel scheduler jitter. Both effects compound into the
crackling the user heard.

Upstream tsclientlib's own audio example
(tsclientlib/examples/audio_utils/ts_to_audio.rs) and the official Qint
client both use SDL2 with AudioSpecDesired { freq: 48000, channels: 2,
samples: 960 }. SDL2 on the same systems routes through PipeWire's PA
bridge (or PulseAudio directly), both carrying high-quality resamplers.

Fix:
  * Add sdl2 = '0.37' as a target_os=linux dependency. Links libSDL2-2.0
    .so (Arch sdl2-compat over SDL3, Debian libsdl2-2.0-0, Fedora SDL2).
  * New module crates/chanora_audio/src/sdl_output.rs implementing
    SdlOutput: opens a 48 kHz stereo 960-frame callback that zeroes the
    buffer and calls AudioHandler::fill_buffer directly (no user-side
    resampler). Master gain + hard-mute atomics wired in identically to
    the cpal callback so set_output_gain / set_output_muted keep working.
  * engine.rs cfg-gated: target_os='linux' builds SdlOutput; everywhere
    else continues with the cpal output path (including the device-native-
    rate negotiation and resampler-continuity fixes shipped earlier --
    those remain correct on Windows/macOS where cpal targets WASAPI /
    CoreAudio cleanly).
  * The cpal output helpers (build_output_stream, PlaybackResampleState,
    FromF32) are now cfg(not(target_os='linux'))-gated so the Linux
    build doesn't emit dead-code warnings.

Capture path still cpal on every platform -- outbound audio was not
reported as bad. Resampler-continuity fix on the capture side stays:
microphone -> Opus encoder still goes through the linear interpolator
with the last-sample anchor.

Tests: 32 / 0 / 0 (chanora_audio), workspace 78 / 0 / 1 unchanged.
2026-05-16 12:15:00 +08:00
EdisonJwa 8acd456af1 feat(protocol): per-platform TS3 client_version selection
ConnectOptions previously took tsclientlib's default Version. Servers that
strictly check the announced client signature could refuse or downgrade
those sessions. Add pick_client_version() that selects a stable signed
descriptor matching the runtime OS:

  Windows  -> Version::Windows_5_0_0_beta51
  Linux    -> Version::Linux_5_0_0_beta51
  macOS    -> Version::macOS_5_0_0_beta51
  Android  -> Version::Android_3_5_0__7
  iOS      -> Version::iOS_3_5_6
  other    -> Linux fallback

All five variants are guaranteed to exist in the vendored tsproto-types
enum at compile time; build fails loudly if upstream removes one.

Wired into Connection::build(...).version(pick_client_version()) on every
connect. Emits 'selected TS3 client_version' info log line so the choice
is visible in chanora.log.
2026-05-16 12:14:37 +08:00
EdisonJwa 73066749e3 fix(audio,linux): isolate zbus blocking probe on a fresh OS thread
LinuxGnomeWaylandBackend::probe() called zbus::blocking::Connection::session()
directly. The blocking facade internally constructs a current-thread tokio
runtime and block_on()s its async D-Bus client. probe() runs from
PttController::new (sync) which is called from start_audio (async on the
bridge tokio runtime). Nested runtimes panic with 'Cannot start a runtime
from within a runtime'.

Symptom on Linux: the first voice-channel join surfaced a SnackBar
'Could not join channel: join: task N panicked ...' while the channel-move
command had already succeeded server-side. User saw 'channel joined but voice
not enabled'.

Fix: run the cheap blocking probe on a dedicated std::thread (no ambient
runtime), join it synchronously, propagate the version / error. Probe is
microseconds; the join cost is negligible.
2026-05-16 12:14:28 +08:00
EdisonJwa 6df5ea960e chore: ignore opencode.json agent-local config 2026-05-16 12:14:19 +08:00