Commit Graph
184 Commits
Author SHA1 Message Date
Edison Jwa 5c3dd70bba fix(ios-audio): activate session before voice joins (#38)
* fix(ios-audio): add voice join session coordinator

* fix(ios-audio): activate session before voice joins

* docs(ios-audio): align activation lifecycle comments

* fix(ios-audio): keep session active when already-in-channel

The 'already in channel' server response (code 0x0302) is treated as a
successful join by _onJoinChannel: the user stays in the channel and
local state is updated to reflect the joined target. But the underlying
voiceJoin call still raises BridgeError_ServerRejected, which the
joinVoiceChannelWithIosAudioSession helper used to interpret as a join
failure and deactivate the iOS audio session. Result: the UI shows the
user as joined while the audio session is dead and capture/playback
remain silent.

Add an isJoinSuccess predicate to the ordering helper. When the
predicate matches, the helper rethrows (so the caller can still run its
success-on-already-joined branch) without deactivating the session.
Wire _onJoinChannel to pass _isAlreadyInChannel as the predicate so the
0x0302 path keeps the session active.

Adds two regression tests covering the success-on-rethrow and the
predicate-false-still-deactivates paths.

* docs(security): regenerate license inventories

Cargo inventory: pick up chanora_resolver bump from 0.1.0 to
0.2.0-beta.1 so it matches the workspace; also adds a trailing newline
so 'cargo about generate' is idempotent in CI license-drift checks.

Flutter inventory: pick up flutter_local_notifications (+ platform
interfaces) and timezone pulled in by the prior notification
permission work.
2026-06-09 19:58:19 +09:00
Edison Jwa eca77ece81 fix(chat): allow empty poke messages 2026-06-09 01:47:23 +09:00
Edison Jwa 7922eabcf0 build(android): keep notification icon resource 2026-06-08 23:38:55 +09:00
Edison Jwa b565663645 feat(chat): route pokes through notifications 2026-06-08 22:56:52 +09:00
Edison Jwa 34a5247457 feat(ui): add poke notification settings dialog 2026-06-08 22:56:04 +09:00
Edison Jwa 9a5f82565d feat(l10n): add poke notification settings copy 2026-06-08 22:55:38 +09:00
Edison Jwa 409cd11c21 feat(flutter): persist poke notification preferences 2026-06-08 22:55:12 +09:00
Edison Jwa 4f1b85cf76 feat(flutter): add poke notification service 2026-06-08 22:54:46 +09:00
Edison Jwa b7cc4d2336 build(apple): declare notification permission usage 2026-06-08 22:54:18 +09:00
Edison Jwa 44f91a2ea4 build(android): configure local notifications 2026-06-08 22:53:53 +09:00
Edison Jwa cc6db18199 build(flutter): add local notification plugin 2026-06-08 22:53:29 +09:00
Edison Jwa 6af2ed9ab5 feat(flutter): regenerate poke strength bridge 2026-06-08 22:53:01 +09:00
Edison Jwa d83539436e fix(voice): preserve mute owners and release touch ptt 2026-06-08 18:00:32 +09:00
Edison Jwa a0ff17b935 fix(voice,ios): scope AVAudioSession VoiceChat to call lifetime (#33)
Adopt a call-scoped VoIP audio session lifecycle so other apps' audio is
not stopped while Chanora is idle and the in-call session does not get
clobbered by media-server resets unrelated to voice.

AppDelegate.swift
- Set .ambient + .mixWithOthers as the idle baseline so the app does not
  hold a VoiceChat session when no call is active.
- Switch to .playAndRecord + .voiceChat + .mixWithOthers + .duckOthers
  on demand via the new chanora/ios_audio_session MethodChannel, and
  revert to .ambient on deactivate.
- Gate the media-services-reset rebuild on voiceSessionActive so a
  stray reset during idle no longer reactivates VoiceChat.

ios_audio_session_controller.dart (new)
- Thin Dart wrapper around chanora/ios_audio_session with activate/
  deactivate; no-op on non-iOS; swallows PlatformException to keep
  audio start/stop resilient to platform-side races.

main.dart
- Activate the iOS audio session on BridgeEvent_AudioStarted, deactivate
  on BridgeEvent_AudioStopped, fire-and-forget via unawaited().

ios_voice_unit.rs
- Add the 10 local bindings required by the render-callback closure
  preamble (wav_recorder_for_render, render_recorder_active,
  render_ref_len, render_ref_accum, cb_count, last_num_frames,
  num_frames_changes, callbacks_with_audio, callbacks_with_silence)
  so the iOS target compiles cleanly with the new lifecycle wiring.

Tests
- 5 unit tests in test/services/ios_audio_session_controller_test.dart
  cover activate/deactivate on iOS, no-op on non-iOS, and graceful
  PlatformException handling.

Docs
- SRS SRS-110 expanded to cover the call-scoped lifecycle invariant.
- SysDes mobile-voice row updated to reflect the MethodChannel and
  .ambient idle baseline.
- implementation-status-2026-05-28 voiceChat row flipped to done.

Verification
- flutter analyze: No issues found (2.8s)
- flutter test: 195 passed / 2 skipped / 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
- cargo build -p chanora_audio (macOS host): clean

Device QA matrix (Spotify-keeps-playing-while-idle, mix-during-call,
revert-on-call-end, media-services-reset-during-idle) remains pending
on physical hardware.
2026-06-08 06:02:12 +09:00
Edison Jwa 68a7892e19 fix(macos): address PR #31 review findings
- _showLocalNetworkDeniedSnackBar: wrap Process.run with unawaited()
  and .catchError() so a rejected future (e.g. macOS sandbox refuses
  fork, or 'open' is missing) cannot bubble into the Flutter zone
  as an unhandled exception. The synchronous try/catch was a no-op
  because Process.run only throws asynchronously.
- macos_permissions_service_test.dart: mirror the
  triggerLocalNetworkPrompt error-handling test with one for
  checkLocalNetworkAccess. Probe-path failures (NWConnection probe
  cannot establish, or Swift side throws) must fall back to cached
  state without crashing the caller.

Tests: 186 passed, 2 skipped. Dart analyze clean.
2026-06-07 23:36:31 +09:00
Edison Jwa e048b6b6bd fix(chat): propagate empty draft on target swap and dispose
Oracle re-review on PR #30 flagged that _ChatDetailViewState only
called onDraftChanged when _textCtl.text was non-empty. The empty
case is load-bearing: if the user restored a saved draft, deleted
the text, then switched target (or closed the panel), the parent's
draft map kept the stale entry and resurrected it on the next swap.

Fix: call onDraftChanged unconditionally in both didUpdateWidget
(target change) and dispose (tear-down), so the parent map learns
when a draft is now empty.

Adds a regression test exercising the restore-clear-swap sequence.
2026-06-07 23:30:00 +09:00
Edison Jwa dad633e381 fix(ui): address PR #30 review findings
- ViewportInfo.updateShouldNotify: compare layoutClass only
  (not width/height), avoiding unnecessary rebuilds on every
  resize frame within the same layout class.
- ChatPanel: use BorderDirectional(start:) for RTL support.
- ChatPanel: localize 'Close chat' tooltip via AppL10n.chatCloseAction.
- Inline panel snackbar: localize via AppL10n.chatPanelCollapsedHint.
  New en/zh ARB entries added.
- _saveCurrentDraft(): removed — it was a self-assignment no-op.
  Draft persistence relies on ChatDetailView's didUpdateWidget
  (fires onDraftChanged on target switch) and dispose (fires on
  panel tear-down), both of which already populate _chatDrafts
  correctly without an explicit save call.
- _handleInlineChatViewport layout snackbar: use AppL10n.
- Audio level-meter: switch from callback-count (% 3) to time-based
  gating (std::time::Duration::from_millis(33)), robust to cpal
  buffer-size or sample-rate changes. Remove level_decimation_counter.
- chat_panel_test.dart: add AppL10n.localizationsDelegates so the
  test resolves l10n keys.

Tests: 183 passed, 2 skipped. Dart analyze clean.
cargo test -p chanora_audio --lib: 125 passed.
2026-06-07 23:30:00 +09:00
Edison Jwa e9cd832828 docs(macos): clarify network.server entitlement justification
Oracle re-review nit on PR #27: cite Apple's App Sandbox semantics
explicitly. The macOS sandbox classifies any UDP bind() against a
local port as a 'server' operation (covered by network.server),
even when the socket is only used to sendto() a remote peer. This
is the bind()-then-sendto() pattern tokio's UdpSocket uses
internally for tsclientlib's outbound voice traffic. Correct the
sandbox log line to the actual deny string ('Sandbox: ... deny(1)
network-bind') and reference Apple's entitlement reference wording.
2026-06-07 23:27:46 +09:00
Edison Jwa c40705790a fix(audio,macos): address PR #27 review findings
- ios_voice_unit.rs: add producer_shutdown AtomicBool flag (macOS only).
  The macOS start path spawns a tokio producer task that holds clones
  of Arc<Mutex<AudioHandler>>, Arc<ArrayQueue<f32>>, and the output
  gain/muted atomics, then loops on a 20 ms tokio interval. Without
  a shutdown signal the task runs forever on engine stop/restart and
  leaks all four Arcs every cycle. Drop now stores 'true' on the
  flag; the producer checks it at the top of each tick and exits,
  releasing its clones within at most one 20 ms tick.

- macos/Runner/Release.entitlements: strengthen the existing
  justification comment for com.apple.security.network.server.
  Document the specific failure mode (tokio::net::UdpSocket::bind
  -> sandbox 'network-outbound deny' -> EPERM) and explain why
  network.client alone does not cover bind()-then-sendto. The
  entitlement is required, not over-broad.

cargo check (host + aarch64-apple-darwin): clean
cargo test -p chanora_audio --lib: 133 passed
flutter test: 186 passed, 2 skipped
dart analyze: clean
2026-06-07 23:27:46 +09:00
Edison Jwa fe5dc1cda4 feat(macos): add macOS audio lifecycle MethodChannel
apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift (new): native MethodChannel handler for chanora/macos_audio_lifecycle. Observes Core Audio HAL default-input and default-output device property changes via AudioObjectAddPropertyListener; posts handleDefaultDeviceChange events with {role: input|output} payload. Mirrors the iOS chanora/ios_audio_lifecycle event surface minus the AVAudioSession-specific events (no interruption / no media services reset equivalents on macOS — no AVAudioSession).

apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift: register MacOSAudioLifecycle next to MacOSPermissionsHandler in awakeFromNib. Closes the iOS/macOS asymmetry noted in SysRS-051.

apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: add wireMacosAudioLifecycle() parallel to wireIosAudioLifecycle() / wireAndroidAudioLifecycle(). The current implementation captures and logs the events; the FRB function that triggers a VPIO re-bind on the engine is a follow-up. Event-shape mirrors the iOS side so a future caller can switch on platform without changing the dispatch shape.

apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: smoke test for wireMacosAudioLifecycle (4 tests pass, including the new one).
2026-06-07 23:27:46 +09:00
Edison Jwa 1cf1a8f5a6 fix(macos): add network.server entitlement to release sandbox for UDP bind
apps/chanora_flutter/macos/Runner/Release.entitlements: add com.apple.security.network.server = true. The macOS App Sandbox treats every UDP bind() — including the ephemeral 0.0.0.0:0 that tsclientlib uses for outbound TS3 traffic — as a server operation. Without this entitlement UdpSocket::bind fails with EPERM and the TS3 connect never starts. Debug builds already had this entitlement (needed for flutter run hot-reload); release builds were missing it.

apps/chanora_flutter/macos/Runner/DebugProfile.entitlements: expand the existing network.server comment to document the dual rationale (flutter hot-reload + outbound UDP bind), so the entitlement's purpose is clear without spelunking through tsclientlib.
2026-06-07 23:27:46 +09:00
Edison Jwa ebae1274d6 fix(audio,ui): address PR #27 review findings
- ios_voice_unit.rs:856-868: Revert iOS render callback from blocking
  lock() back to try_lock() with silence-on-contention (WouldBlock
  branch increments callback_xrun stat and returns the pre-zeroed
  scratch buffer). Blocking lock() inside the CoreAudio HAL render
  callback can stall the realtime IO thread when the decode task on
  engine.rs:1309 holds the same AudioHandler Mutex, re-introducing
  the underrun pattern this codebase already fixed elsewhere.

- ios_voice_unit.rs:782: Preallocate scratch_stereo to Apple's VPIO
  MaximumFramesPerSlice (4096 frames * 2 channels = 8192 f32) at
  setup time, so the realtime render callback never grows the Vec
  via resize(). The defensive 'len() < needed' branch is kept for
  the (impossible) case that the audio unit later raises max frames.

- main.dart:52-56: Gate _showAudioDebugOverlay behind kDebugMode &&
  _isMacOS so the internal audio stats panel does not ship in
  release builds. kDebugMode is a Dart compile-time const, so the
  overlay subtree is tree-shaken out of release/profile binaries.

- Rebuilt macOS chanora_bridge.framework binary (universal arm64 +
  x86_64) from the fixed source with the new CARGO_PROFILE_RELEASE_*
  env vars (DWARF preserved for dsymutil). install_name patched back
  to @rpath/chanora_bridge.framework/Versions/A/chanora_bridge.

  Verified:
    cargo test -p chanora_audio --lib: 129 passed, 0 failed
    cargo check -p chanora_audio --target aarch64-apple-ios: clean
    dart analyze lib/main.dart: no issues
    xcrun lipo -archs: x86_64 arm64
    xcrun otool -D: @rpath install_name preserved
2026-06-07 23:27:46 +09:00
Edison Jwa 23bfddb6b4 feat(macos): lock-free audio event queue and channel-aware render downmix
macOS realtime audio was suffering buffer underruns on CoreAudio's VPIO
output callback. Root cause was twofold: AudioHandler was decoded under
a Mutex held across the realtime callback, and the render path
hard-coded mono i16 output regardless of the channel count the
callback actually exposed (CoreAudio occasionally hands the callback
stereo or quad output buffers, in which case writing only every Nth
sample produced silence + clicks).

This change brings macOS in line with the lock-free Android audio
architecture introduced for output stutter elimination:

* chanora_audio: AudioPacket / AudioCommand / AudioEventQueue
  (previously gated to `target_os = "android"`) are now compiled on
  macOS too. The decode loop in AudioEngine pushes inbound packets
  into the queue; the VPIO render callback owns AudioHandler outright
  and drains the queue, so the realtime thread never blocks on a
  cross-thread mutex. set_client_volume also routes through the
  command queue on macOS instead of locking the handler.

* voice_render.rs: new downmix_stereo_f32_to_interleaved_i16 helper
  downmixes stereo f32 from AudioHandler to mono i16 and replicates
  that mono sample across every output channel the callback exposes.
  The existing downmix_stereo_f32_to_mono_i16 helper is retained for
  iOS, where VPIO is reliably configured for single-channel output
  via the AudioUnit stream format we pin at unit-create time.
  Compile-gated to ios + test so the macos build doesn't warn on
  dead code.

* ios_voice_unit.rs: render callback reads data.channels from the
  args struct and forwards it to the new interleaved helper, so the
  macOS path tolerates whatever channel count CoreAudio assigns. A
  level decimation counter avoids running sqrt+log10 on every
  callback (~93 Hz) when the Flutter consumer only reads at 30 Hz;
  same regression class as the capture-side fix already in engine.rs.

* mobile_voice_backend.rs: VoiceAudioParams now carries
  event_producer on macOS, and the AudioHandler is no longer wrapped
  in Arc<Mutex<…>> on macOS because ownership moves into the render
  callback. iOS keeps Arc<Mutex<…>> because its callback design
  shares the handler with the decode task.

* lib.rs: audio_event_queue module is now compiled on macOS in
  addition to android.

apps/chanora_flutter/lib/main.dart wraps the home tree in a Stack and
overlays AudioDebugStatsPanel on macOS so the live engine counters
(callback rate, drift, queue depth) used to diagnose the underrun are
visible while iterating on this code. iOS and other platforms are
unaffected.

apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework binary
is rebuilt with these changes so flutter run on macOS picks up the new
realtime path without requiring developers to rebuild the Rust crate
locally. cargo check -p chanora_audio passes on macOS host.
2026-06-07 23:27:46 +09:00
Edison Jwa 1bc2fccd0a fix(ios,macos): add -u force-undefined linker flags for @_cdecl symbols
Oracle re-review on PR #26 flagged that the Swift-side
`_ = unsafeBitCast(fn as @convention(c) ...)` static references in
ChanoraSileroSelfTest.run() are not a robust anti-dead-strip guarantee
under WMO + LTO. The optimizer can prove the discarded result has no
side effects and eliminate the address-taken reference.

The load-bearing fix is a second linker flag per symbol:

  -u _sym                forces the symbol as undefined at link time,
                         preventing the object that defines it from
                         being dropped and stopping -dead_strip from
                         removing the definition.
  -exported_symbol _sym  was already present; re-exports the symbol
                         in the binary's dynamic symbol table so the
                         Rust framework's dlsym(RTLD_DEFAULT) can find
                         it. This flag alone does NOT prevent dead-
                         strip; it only controls the export list
                         applied AFTER dead-strip.

Both flags now appear per symbol on both iOS and macOS Release
xcconfigs. The Swift-side static references stay as defense-in-depth
but are no longer the load-bearing guarantee.
2026-06-07 23:12:07 +09:00
Edison Jwa 80c73f34c3 fix(ios,macos): add static @_cdecl references to defeat dead-strip
PR #26 review (Oracle): dlsym(RTLD_DEFAULT, name) does NOT count as
a static linker reference, so the @_cdecl Swift functions were still
eligible for dead-stripping under Whole-Module-Optimization + LTO
in Xcode Archive builds. This is the actual root cause of the
TestFlight regression — the prior verify_silero_exports.sh fix only
catches the symptom (missing symbol) at build time, it does not
prevent the stripping.

The fix adds 6 static '_ = unsafeBitCast(<fn> as @convention(c) ...)'
references inside ChanoraSileroSelfTest.run() before the existing
dlsym probe. The @convention(c) cast forces address-taken semantics,
which the optimizer cannot prove unused.

Applied identically to ios/Runner/SileroCoreMLBridge.swift and
macos/Runner/SileroCoreMLBridge.swift (the files were and remain
byte-identical).

cargo check --workspace: clean
dart analyze: clean
2026-06-07 23:12:07 +09:00
Edison Jwa 9d8a1f8fd1 fix(ios,macos): address PR #26 review findings
- ios/Runner.xcodeproj/project.pbxproj: Update RunnerTests TEST_HOST
  paths from Runner.app/Runner to Chanora.app/Chanora (target was
  renamed in prior commit but test config still pointed at old paths,
  breaking xcodebuild test).
- Cargo.toml: Move release DWARF flags from workspace [profile.release]
  into Apple-only podspec CARGO_PROFILE_RELEASE_* env vars so Android,
  Linux, Windows release builds stay lean (~10MB DWARF avoided).
- ios/Runner/Info.plist + macos/Runner/Info.plist: Flip
  ITSAppUsesNonExemptEncryption from false to true (Chanora ships
  ChaCha20-Poly1305 local storage + tsclientlib ECDH/AES-EAX voice
  channel encryption, not exempt under Apple export-compliance rules).
- scripts/verify_silero_exports.sh: Make slice-aware via lipo -archs
  loop + per-arch nm -arch invocation so universal macOS builds
  verify every architecture slice, not just whichever slice nm picks.
- .gitignore: Drop .omo/ and .playwright-mcp/ entries (scope leak;
  unrelated tooling state, not part of PR #26 archive-symbol concern).
2026-06-07 23:12:07 +09:00
Edison Jwa a589ac953f fix(ios,macos): preserve Silero @_cdecl exports across Xcode Archive
The Apple CoreML Silero VAD backend resolves six @_cdecl Swift symbols
via dlsym(RTLD_DEFAULT) at runtime in the Rust audio crate. Local
flutter build paths preserved those symbols, but Xcode Archive (the
path used for TestFlight and App Store uploads) silently stripped them
through two independent mechanisms, causing Rust to fall back to
WebRTC VAD on every shipped build.

Both stripping mechanisms are now neutralised:

* ld dead-strip: OTHER_LDFLAGS now whitelists each of the six
  chanora_silero_vad_* symbols via repeated `-Xlinker -exported_symbol`
  pairs in ios/Flutter/{Release,Debug}.xcconfig and
  macos/Flutter/Flutter-{Release,Debug}.xcconfig.
* install-time strip: STRIP_STYLE is set to `non-global` in the same
  four xcconfigs so the post-link strip phase no longer drops exported
  global text symbols from the Archive product. Cost: ~264 bytes per
  binary; verified `xcrun strip` vs `xcrun strip -x` behaviour.

Self-test wired into both AppDelegates: at launch on a utility queue,
ChanoraSileroSelfTest resolves all six symbols through dlsym (the same
path the Rust runtime uses, not a direct call that would mask the bug
class) and exercises create → reset → process → destroy. Result is
logged via NSLog and surfaces in Console.app / idevicesyslog.

A post-link verify_silero_exports.sh build phase runs nm -gU on the
final Archive binary and fails the build if any of the six symbols are
missing. Empirically caught the original Archive regression that
flutter build --no-codesign did not.

CocoaPods bridge podspecs now emit a proper .dSYM via dsymutil so
TestFlight crash reports are symbolicated; Cargo.toml release profile
sets `debug = true` because dsymutil needs DWARF in the input dylib.

macOS chanora_bridge.podspec PATH inserts /opt/homebrew/opt/rustup/bin
ahead of /opt/homebrew/bin so rustup's cargo (which has the
x86_64-apple-darwin target installed) wins over the homebrew rust
formula that is aarch64-only.

iOS Podfile target renamed from `Runner` to `Chanora` to match the
Xcode target name shipped in the project (the workspace and scheme
already referenced Chanora; the Podfile mismatch produced lint
warnings during `pod install`).

ITSAppUsesNonExemptEncryption=false declared in both Info.plist files
so TestFlight and App Store Connect uploads skip the export-compliance
prompt; Chanora uses only platform-provided TLS.

.gitignore now covers Xcode archive bundles, IPA exports, dSYM
directories, the local macOS release zip, and agent/tooling state
directories so generated TestFlight artifacts no longer appear in
git status.

End-to-end verified by headless archive:
  xcodebuild -workspace Runner.xcworkspace -scheme Runner \
    -configuration Release -destination 'generic/platform=iOS' \
    -archivePath /tmp/chanora.xcarchive archive CODE_SIGNING_ALLOWED=NO
nm -gU on the resulting .app/Chanora binary shows all six
chanora_silero_vad_* symbols present.
2026-06-07 23:12:07 +09:00
Edison Jwa ad8b996376 fix(macos): reliable Local Network permission denial detection and re-check
- Replace broad POSIX error checks (EACCES/EPERM/ENETDOWN) with the
  canonical kDNSServiceErr_PolicyDenied DNS error in the NWBrowser
  state handler, matching the pattern used by Expo, Pulse, Strongbox,
  and WLED. Detect denial in both .failed and .waiting states.

- Add checkLocalNetworkAccess(host:port:) — a read-only NWConnection
  probe (Sequel-Ace pattern) that checks
  NWPath.unsatisfiedReason == .localNetworkDenied without triggering
  a new system prompt. Useful for confirming denial against a specific
  destination before attempting to connect.

- In _onConnect, after the prompt resolves to Denied, confirm with
  checkLocalNetworkAccess against the target host. If confirmed,
  abort the connect attempt and show a non-modal snackbar with an
  'Open System Settings' action that deep-links to
  Privacy_LocalNetwork. Previously the app would proceed to connect,
  fail with PermissionDenied, and surface a redundant in-app modal.

- Drop the now-orphaned _openIosAppSettings helper and
  _iosPlatformChannel constant (the only caller was the removed
  in-app permission dialog).

- Add unit tests for checkLocalNetworkAccess covering outbound
  MethodCall arguments and state parsing for Granted/Denied.

Trace: SRS-300.
2026-06-07 23:12:07 +09:00
Edison Jwa ab0dc2ebc2 fix(macos): trigger Local Network permission prompt before server connect
The Local Network permission prompt (NWBrowser for _ts3._tcp) was
never actually triggered anywhere in the app. The service defined
triggerLocalNetworkPrompt() but no code called it.

Now _onConnect() checks the local network state before connecting.
If the state is unknown or notDetermined, it triggers the NWBrowser
scan which shows the system Local Network Privacy dialog on macOS 15+.
This ensures the prompt appears before the connection attempt so the
user can grant permission and the connection succeeds in one flow.

Non-macOS platforms are unaffected (short-circuited by the service).
2026-06-07 23:12:07 +09:00
Edison Jwa 5e8b7915db feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix
- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024)
- Add ViewportInfo InheritedWidget for layout-aware descendants
- Add inline ChatPanel (380dp right column) for expanded desktop layout
- Add channel right-click context menu with Chat option for in-place switching
- Add per-target draft persistence via restoredDraft/onDraftChanged callbacks
- Fix header chat button to switch to current voice channel when panel open
- Fix close = dismiss (preserves last target and draft for reopen)
- Add unread dot indicator on channel tiles when chat is closed
- Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz)
  to avoid buffer underruns on macOS CoreAudio real-time thread
- Add tools/build-macos.sh release build script (7-step process)
- Add chat panel switching implementation plan and 3-panel design spec

Tests: 183 passed, 2 skipped. Flutter analyze clean.
2026-06-07 23:12:07 +09:00
Edison Jwa e7f7c55b30 docs(audio): correct iOS producer name in parseBridgeAudioRoute
Oracle re-review pass on PR #28 flagged that the doc comment named
IOSAudioLifecycleController.classifyDevice, but no such class exists
in the repo. The actual iOS classifier is
AppDelegate.classifyAudioRoute(_:) in ios/Runner/AppDelegate.swift
(line 292), invoked from the route-change and media-services-reset
handlers (lines 189, 237).

Android side is correct: AndroidAudioLifecycleController.classifyDevice
exists at android/app/src/main/kotlin/app/chanora/chanora_flutter/
AndroidAudioLifecycleController.kt.
2026-06-07 23:11:12 +09:00
Edison Jwa bb73a94e2c docs(audio): document parseBridgeAudioRoute case-sensitivity contract
Adds a doc comment to parseBridgeAudioRoute clarifying that both
iOS and Android producers (IOSAudioLifecycleController.classifyDevice
and AndroidAudioLifecycleController.classifyDevice) emit exact
PascalCase strings.

Case variants (USB_HEADSET, usb_headset, UsbHeadphone) fall through
to unknown by design. This is a silent failure mode worth documenting
so future changes to either platform classifier are paired with a
parser update.

Per PR #28 review feedback.
2026-06-07 23:11:12 +09:00
Edison Jwa e3286f1197 fix(audio,flutter): parse Android UsbHeadset and Hdmi route strings
apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: extend parseBridgeAudioRoute to handle 'UsbHeadset' (maps to wiredHeadset — USB audio is functionally a wired-class device, matching AndroidAudioLifecycleController.classifyCurrentRoute's own preference ordering at line 176) and 'Hdmi' (maps to unknown — HDMI is a display-out transport, not a voice-call audio path; no existing BridgeAudioRoute variant fits; safer to leave as unknown than to misclassify as Speaker). Previously these Android-emitted strings hit the default branch and silently became BridgeAudioRoute.unknown.

apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: split the existing single test into three — iOS-classified strings (preserved), Android UsbHeadset (new), Android Hdmi (new). flutter test: 3 passed, 0 failed.
2026-06-07 23:11:12 +09:00
Edison Jwa 8105b7af21 chore(repo): untrack macOS chanora_bridge.framework build artifacts
The macOS chanora_bridge.framework tree at
apps/chanora_flutter/macos/Frameworks/chanora_bridge.framework was
being tracked in git despite being a pure build artifact. Both
mechanisms in chanora_bridge.podspec rebuild the entire tree from
scratch:

  * prepare_command (runs on `pod install`) — `rm -rf $FW` and
    reconstructs Versions/A, the Versions/Current and Resources
    symlinks, Info.plist, and copies the lipo-merged universal dylib.
  * script_phase :before_compile (runs on every Xcode build) — same
    rm -rf + reconstruction, gated on freshness of the cargo output.

Tracking the tree therefore added zero value and ~36 MB per binary
revision (the chanora_bridge dylib alone). The iOS counterpart at
apps/chanora_flutter/ios/Frameworks/ has been correctly ignored since
.gitignore:118-119 was added; this commit mirrors that rule for macOS.

Changes:
  - git rm --cached -r the 5 tracked entries (binary, 3 symlinks,
    Info.plist). Working tree is untouched, so existing local
    builds keep functioning until the next `pod install` /
    Xcode build refreshes them.
  - Add /apps/chanora_flutter/macos/Frameworks/ to .gitignore
    alongside the existing iOS entry, with a comment pointing at the
    podspec mechanism so the next maintainer understands the rule.

Verified the working tree binary survives the cache untrack and
the path is now matched by .gitignore:125.
2026-06-07 22:54:14 +09:00
Edison Jwa 5f1423c349 feat(voice): unified mobile voice bar with gesture-isolated PTT row (#22)
* feat(voice): unified mobile voice bar with gesture-isolated PTT row

Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode

Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)

* fix(voice): preserve current PTT button format

* feat(voice): move mute/deafen controls into VoiceStatusChip

* fix(voice): ensure consistent chip height across mute states

Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip.
Material 3 toggle IconButtons (_SelectableIconButton) can vary in height
when the selected state changes due to tap target sizing. Use simple
conditional icons instead and set shrinkWrap tap target size with tight
constraints for stable 40x40 buttons regardless of state.

* fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip

* fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip

Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in
the oboe output callback with `bytemuck::cast_slice_mut`, eliminating
the unsafe block and relying on bytemuck compile-time NoUninit
verification instead.

Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only
remaining hard-coded English tooltip in VoiceStatusChip with it.
2026-06-05 20:58:16 +09:00
Edison Jwa 82441f3d97 feat(voice): real-time mic input level metering at 30 Hz (#25)
* feat(voice): add real-time mic input level metering at 30 Hz

Expose input RMS from the audio engine through the bridge as a
dedicated Rust→Dart Stream<double>, replacing the binary on/off
indicator with a proportional dBFS level meter.

Rust side:
- chanora_audio: add set_input_dbfs/input_dbfs accessors to
  SharedAudioProcessingStats; restructure CaptureState::ingest()
  to compute dBFS from mono buffer before the PTT guard so the
  meter shows mic activity even when not transmitting.
- chanora_core: widen audio_stats() return to include f32 input
  level.
- chanora_bridge: add input_level: f32 to BridgeAudioStats and
  new input_level_stream(sink: StreamSink<f32>) that pushes at
  ~30 Hz via tokio interval task.
- Update frb_generated.rs serialization for the new field.

Flutter side:
- VoiceLevelMeter: accept optional double level (dBFS), map
  -60..0 dBFS to 0..1 fill fraction, animate with
  TweenAnimationBuilder for smooth transitions.
- voice_compact.dart: subscribe to inputLevelStream in the voice
  details sheet for 30 Hz meter updates, keeping 250 ms poll for
  TX/RX counters.
- voice_bar.dart: accept optional inputLevel from the stream.
- main.dart: subscribe to inputLevelStream, pass to VoiceBar.

* chore: sync Flutter build config and dependency updates

- Add Flutter migrator flags to gradle.properties (builtInKotlin, newDsl)
- Add FlutterGeneratedPluginSwiftPackage to iOS/macOS Xcode projects
- Update meta 1.17→1.18, test_api 0.7.10→0.7.11
- Rebuild chanora_bridge framework for macOS
- Update Podfile.lock for iOS and macOS

* fix(voice): correct meter animation, pre-gain dBFS, stream lifecycle, and protocol warnings

B1: Convert VoiceLevelMeter to StatefulWidget tracking previous fill
     as Tween begin so the meter animates smoothly instead of resetting
     to zero on every frame.

B2: Compute dBFS from pre-gain mono samples in CaptureState::ingest()
     so the level meter reflects raw mic input, matching mobile paths.

B4: End input_level_stream after 10 consecutive session errors instead
     of emitting -120 dBFS forever when the session is gone.

Also fixes all 13 clippy warnings in chanora_protocol: collapsed
nested if-let patterns, replaced .ok() + Some matching with Ok, used
? operator, and introduced EventChannels struct to reduce the four
helper functions below the 7-argument threshold.

* fix(voice): use MissedTickBehavior::Skip for level meter stream and align dBFS doc

Set MissedTickBehavior::Skip on the input_level_stream tokio interval
so slow audio_stats() calls skip missed ticks instead of bursting,
preventing CPU spikes on the UI meter thread.

Align VoiceLevelMeter class doc: the mapping floors at -60 dBFS
(via dbfsToFraction), not the full -120 range.
2026-06-05 20:57:16 +09:00
Edison Jwa 2c7b68e21e chore(android): upgrade toolchain to AGP 8.13.1 / Kotlin 2.3.0 (#23)
Bump Android Gradle Plugin from 8.11.1 to 8.13.1 and Kotlin from
2.2.20 to 2.3.0 to align with newer plugin version requirements.

Kotlin 2.3.0 removed the kotlinOptions DSL free-string assignment.
Migrate to the compilerOptions DSL for JVM target configuration.

Gradle wrapper remains at 8.14 (compatible with AGP 8.13.1).
2026-06-05 20:54:51 +09:00
Edison Jwa 12e3a1f4ee feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications (#21)
* feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications

Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift)
for macOS-specific permissions not covered by permission_handler:

- Input Monitoring (CGPreflightListenEventAccess /
  CGRequestListenEventAccess) for global PTT via Event Tap
- Local Network Privacy prompt (NWBrowser for _ts3._tcp, macOS 15+)
- Notifications (UNUserNotificationCenter authorization)

Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091

Changes:
- Info.plist: add NSBonjourServices array with _ts3._tcp
- macos_permissions_service.dart: Dart service with MethodChannel,
  ValueNotifier states, PTT capability derivation (L0Focused /
  L1MacOSEventTap), non-macOS short-circuit
- MainFlutterWindow.swift: native handler registered as FlutterPlugin,
  Input Monitoring check/request/polling, NWBrowser trigger with
  denial detection, UNUserNotificationCenter request
- main.dart: wire service into bootstrap lifecycle, listen for PTT
  capability changes from Input Monitoring state
- macos_permissions_service_test.dart: 17 unit tests covering inbound
  state changes, outbound calls, lifecycle, error handling, platform
  behavior (179/179 full suite pass)

* fix(macos): keep permissions capability state live
2026-06-05 14:26:11 +09:00
Edison Jwa 2f47e7ccee chore: prepare v0.3.0 release
bench-advisory / bench-advisory (push) Failing after 7m7s
ci / cargo check + cargo test (push) Successful in 10m56s
ci / cargo deny (licenses + advisories + bans + sources) (push) Successful in 53s
ci / cargo about (license inventory) (push) Successful in 10m46s
ci / flutter license inventory (push) Successful in 2m13s
ci / flutter analyze (push) Successful in 36s
ci / flutter iOS unsigned release build (push) Has been cancelled
- Rewrite CHANGELOG.md with v0.3.0 entry
- Bump version to 0.3.0+100
- Fix split-per-abi Android APK builds
2026-06-03 19:20:33 +09:00
Edison Jwa 29afbb5e97 fix: address post-event-driven issues and client info parity (#16)
* fix(ui): restore speaking status indicators

Speaking state (isSpeaking) is computed from voice activity timestamps
in the protocol layer and cannot be represented as a discrete delta.
The event-driven refactor removed periodic snapshot refreshes, causing
speaking indicators to go stale.

Adds a 750ms periodic snapshot refresh (matching SPEAKING_ACTIVITY_WINDOW)
while the audio stats timer is active (in-channel only). Structural
changes (moves, joins, leaves) are still handled by instant deltas.

* fix(ui): hide server query clients from delta joins

When a ServerQuery client sends a message, a ClientJoined delta fires.
Before PR#15 the periodic snapshot rebuild would include the SQ client
but the snapshot_view filter hid it. With deltas, the client persisted
in the local snapshot. Now ClientJoined deltas skip SQ clients entirely.

* fix(proto): log getconnectioninfo errors instead of silently discarding

Ping and packet loss showing 'Unknown' in the client info sheet is
caused by getconnectioninfo failures being silently swallowed. Now
logs the error with the client_id so the root cause can be diagnosed
(e.g. missing b_client_connectioninfo_view permission on the server).

Also logs clientgetvariables failures.

* fix(proto): refresh non-self client profiles before mapping

* feat(protocol): add ping deviation to client profiles

* chore(ui): regenerate Flutter bridge bindings for ping deviation

* fix(l10n): add ping deviation labels to client info

* feat(ui): show ping deviation in client info sheet
2026-06-03 18:26:29 +09:00
Edison Jwa 808324f374 feat: event-driven UI updates for instant channel switching (#15)
* chore: regenerate Cargo.lock after rebase

* fix(ui): add 1s cool-down to prevent double-tap channel join

voiceJoin returns instantly (fire-and-forget protocol), so the
pending-join guard clears before a second tap lands. The cool-down
prevents the rapid channel oscillation and ClientIsFlooding (524)
that results from double-tapping.

* fix(ui): handle ChannelAlreadyIn as success, ClientIsFlooding with backoff

- ChannelAlreadyIn (0x0302): treat as silent success, update UI state
- ClientIsFlooding (0x020c): show localized snackbar, extend cooldown 5s
- Add l10n strings for flooding error (en + zh)

* fix(proto): use Windows TS3 client version for broadest compatibility

Matches Qint's default (Windows_3_X_X__1). Avoids server-side
behavioral differences with TS5 version strings.

* fix(proto): patch tsproto-types to handle short P-256 coordinates

BigInt::to_bytes_be() strips leading zeros, causing WrongPublicKeyLength
when a server's ephemeral key coordinate starts with 0x00. Patch from
EdisonJwa/tsclientlib fix/p256-short-coordinate-pad branch left-pads
coordinates to the P-256 field size instead of rejecting them.

* refactor(core): stop watchdog from emitting SnapshotChanged

The watchdog now serves only as a liveness probe (miss counting for
reconnection). UI updates are handled entirely by the event-driven
delta path (ProtocolDelta → SessionEvent → BridgeEvent → Flutter).

Removes signature tracking and SnapshotChanged emission from the
supervisor loop. The initial snapshot is still fetched via the
Connected event handler in Flutter.

* refactor(ui): remove channel-join cooldown guard

With event-driven deltas the UI updates instantly on channel moves,
so the 1-second cooldown is no longer needed. Double-taps are handled
by the server (ChannelAlreadyIn → success) and the pending-channel-id
guard prevents overlapping requests.

Also removes the _lastJoinCompletedAt field entirely.

* fix(core): reattach event forwarders after reconnect

The reconnect path swapped in a new ProtocolClient but never took
chat_rx, activity_rx, or delta_rx from it. After the first reconnect,
the event-driven UI pipeline was dead.

Fix by extracting spawn_event_forwarders() helper called on both
initial connect and reconnect. Also replaces lossy try_recv+sleep
polling with proper recv().await for push-based delivery.

* feat(protocol): enrich delta schema with all snapshot-visible fields

ClientJoined now carries input_muted, output_muted, is_server_query,
talk_power, talk_power_granted. ChannelAdded/ChannelUpdated now carry
has_password and needed_talk_power. ClientUpdated also carries
is_server_query, talk_power, talk_power_granted.

This prevents local snapshot drift where fabricated defaults could
hide password requirements, talk-power restrictions, or client type.

* refactor: remove dead SnapshotChanged variant end-to-end

SnapshotChanged is no longer emitted since the watchdog was refactored
to liveness-only. Removes the variant from SessionEvent, BridgeEvent,
and the Flutter switch statement. FRB bindings regenerated.

* fix(ci): regenerate license inventory and fix iOS submodule fetch

- Regenerate docs/security/license-inventory.md to match current lockfile
- Remove submodules: true from checkout (causes hard fail on private submodule)
- Add explicit git submodule update --init --depth=1 with || true fallback
- Check silero-coreml/Package.swift instead of directory existence
2026-06-03 18:20:33 +09:00
Edison Jwa 2c3c3873dc fix(diag): record bridge and audio interruption diagnostics (#18)
* fix(diag): record bridge events in diagnostics page

Connection lost/reconnecting/disconnected and iOS audio interruption
events now appear in the diagnostics dialog alongside existing error
snackbar entries.

* fix(diag): reduce audio callback sample verbosity to debug

The render callback diagnostic sample logged every 100 callbacks
(~2s) at INFO level, flooding the 256-entry release log buffer and
pushing out useful events. Changed to DEBUG so it only appears in
debug builds with the larger 4096-entry buffer.

* fix(diag): add timestamps to Rust diagnostic log entries
2026-06-03 18:09:25 +09:00
Edison Jwa d886e285d1 build: add silero-coreml submodule (#19)
* build: add silero-coreml as git submodule

Replaces sibling-directory local package with in-repo submodule.
Updates Xcode relative paths and CI checkout to fetch submodules.

* build: add silero-coreml submodule
2026-06-03 18:09:07 +09:00
Edison Jwa 0381be6964 feat(ui): add per-user volume controls (#17) 2026-06-03 18:08:52 +09:00
Edison Jwa 5f7e2f7e97 fix(audio): update CoreML bridge for SileroVADRunner rename
Aligns with silero-coreml class rename to avoid CoreML type collision.
2026-06-02 20:41:30 +09:00
Edison Jwa 80c2ed46bc fix(ci): align Flutter inventory with stable SDK 2026-06-02 19:44:45 +09:00
Edison Jwa 7510bdca73 fix(ci): refresh Flutter license inventory 2026-06-02 19:41:50 +09:00
Edison Jwa 966afd2b53 ci: fix Apple CoreML VAD checks 2026-06-02 19:33:02 +09:00
Edison Jwa 112a563de5 build(macos): link local SileroCoreML package 2026-06-02 01:54:05 +09:00
Edison Jwa cac178f4af build(ios): link local SileroCoreML package 2026-06-02 01:53:21 +09:00