Compare commits

...
Author SHA1 Message Date
Edison Jwa 89bbfa1e18 fix(ios): activate AVAudioSession before connect to fix voice
Root cause: OSStatus 561145187 (!rec) - AVAudioSession was not in
.playAndRecord mode when VoiceProcessingIO tried to start.

Two changes:
1. Activate session BEFORE rust.connect() so it's ready when auto-join runs
2. Defensive activation in Connected handler as backup

This fixes both the !rec error and the subsequent 'voice_in already taken'
error that occurred after auto-join consumed voice_in on failure.
2026-06-10 18:38:11 +09:00
Edison Jwa 0a6ef55937 fix(ios): unblock iOS Debug builds + regenerate FRB for file transfer API (#41)
* fix(ios): preserve Silero VAD symbols in Flutter Debug builds

Flutter Debug builds split user code into a sibling Chanora.debug.dylib
alongside a minimal launcher executable; the six @_cdecl symbols
chanora_silero_vad_* land in the dylib, not the main binary. Two
problems were hiding behind that:

1. Debug.xcconfig only carried -exported_symbol flags, missing the
   load-bearing -u force-undefined flags Release.xcconfig already had.
   Without -u, the linker dropped the Swift @_cdecl symbols (no Swift
   caller exists) before -exported_symbol could re-export them, so the
   dylib shipped without the VAD entry points.

2. verify_silero_exports.sh inspected only ${EXECUTABLE_PATH}, which in
   Debug builds is the launcher stub. Even with the symbols correctly
   landing in the debug dylib, the script falsely reported them missing.

Mirror Release.xcconfig's -u + -exported_symbol pair in Debug.xcconfig
and teach the verify script to prefer Chanora.debug.dylib when it sits
next to the launcher. Release builds are unaffected (single fat binary
remains the inspected target).

* build(flutter): regenerate FRB bindings for file transfer API

Run flutter_rust_bridge_codegen against the post-PR-#40 chanora_bridge
to emit typed Dart bindings for initCache, downloadAvatar, downloadIcon,
clearFileCache, and fileCacheSize. Replaces the dynamic-cast +
NoSuchMethodError-swallowing shims that lib/src/rust/api.dart shipped
as a pre-regen safety net.

Functional change: downloadAvatar/downloadIcon now return Uint8List?
(was List<int>?) directly through the typed dispatch table. Existing
consumers stay source-compatible because Uint8List extends List<int>;
no call sites needed updates.

* fix(ios): set ITSAppUsesNonExemptEncryption to false

App uses only standard/exempt encryption (HTTPS, system-provided crypto);
declaring exempt status removes the App Store export-compliance prompt
at every TestFlight/release upload.
2026-06-10 15:20:59 +09:00
Edison Jwa aa796d7395 feat: file transfer system (avatar/icon download with cacache) (#40)
* docs(architecture): add file transfer design, research, and implementation plan

* feat(cache): add chanora_cache crate with cacache-backed blob cache

- New chanora_cache crate: content-addressed blob store wrapping cacache
- BlobCache API: async put/get/remove/clear/total_size/evict
- Key validation: av_ prefix (32 hex chars), ic_ prefix (decimal digits)
- Cacache provides crash safety, SSRI integrity, content dedup
- Mtime-based eviction via cacache::list_sync + sort by timestamp
- 7 unit tests all passing
- Added to workspace members

* feat(protocol): add file download support for avatars and icons

- Add Request::DownloadFile variant with oneshot reply
- Add ProtocolClient::download_avatar(client_uid) and download_icon(icon_id)
- Track pending file downloads by FiletransferHandle
- Handle StreamItem::FileDownload: read bytes from TCP stream
- Handle StreamItem::FiletransferFailed: map to ProtocolError
- Add ProtocolError::FileTransfer(String) variant
- Add path helper tests for avatar/icon download paths
- No tsclientlib types leak across the adapter boundary

* feat(core): add blob cache wiring and avatar download orchestration

- Add chanora_cache dependency to Cargo.toml
- Add blob_cache field to ChanoraSession (Arc<Mutex<Option<BlobCache>>>)
- Add init_cache() method: creates BlobCache, runs eviction
- Add get_avatar() method: cache-first, download on miss, store in cache
- Add clear_cache() and cache_size() methods for cache management
- Add CoreError::Cache variant for BlobCacheError conversion
- Add avatar_cache integration test

* feat(bridge): add init_cache, download_avatar, and cache management functions

- Add init_cache(dir) bridge function
- Add download_avatar(avatar_hash, client_uid) bridge function
- Add clear_file_cache() and file_cache_size() bridge functions
- Map CoreError::Cache and ProtocolError::FileTransfer in BridgeError

* feat(flutter): add cache initialization wiring and avatar download shims

- Add wireCache() to app_bootstrap using getApplicationCacheDirectory()
- Call wireCache() after wireStorage() in main bootstrap flow
- Add Dart-side initCache and downloadAvatar wrapper shims in api.dart
- Update Cargo.lock for new chanora_cache dependency

* feat(core): FileTransferService with coalescing, throttling, negative cache

- New file_transfer module with FileTransferService struct
- Semaphore(2) throttles concurrent downloads
- In-flight HashMap coalesces duplicate avatar requests
- 5-min negative cache short-circuits ServerRejected misses
- ChanoraSession delegates get_avatar through the service
- connect/disconnect update shared protocol handle
- clear_cache/cache_size delegate to service
- 2 new unit tests (cached hit, negative cache)

* feat(core,bridge): add get_icon with coalescing and negative cache

- FileTransferService::get_icon() mirrors get_avatar pattern
- ChanoraSession::get_icon() delegates through FileTransferService
- Bridge download_icon() exposed for Flutter
- Dart downloadIcon() shim added
- Uses PREFIX_ICON (ic_<crc32u>) cache key format
- 1 new unit test (cached icon hit)

* fix(core,protocol): simplify store_protocol and add download size cap

- store_protocol: always write to shared Arc<Mutex<Option<ProtocolClient>>>;
  the FileTransferService holds the same Arc so it sees updates automatically
- read_download_bytes: reject downloads exceeding 10 MB to prevent
  malicious servers from causing OOM
2026-06-10 11:45:14 +09:00
Edison Jwa 08d7ace25d fix(audio,flutter): restore VAD on iOS and fix Android build
- Add iOS to voiceActivityTransmitAvailable — iOS has CoreML Silero
  VAD pipeline (AppleCoreMlVadWorker) but was excluded by DEC-030
  gating that predated the CoreML integration
- Inline deleted VadWorkerPolicy in android_voice_unit.rs — PR #37
  removed the enum from vad/mod.rs but missed updating Android
2026-06-10 09:18:32 +09:00
Edison Jwa ddd977796f Merge pull request #39 from EdisonJwa/refactor/remove-ios-raw-unit
refactor(audio): remove experimental iOS RemoteIO+WebRTC APM path
2026-06-10 00:53:43 +09:00
Edison Jwa 2b285491d0 refactor(bridge): remove SonoraExperimental from bridge API and regenerate FRB
Remove SonoraExperimental variant from BridgeIosVoiceProcessingMode
and collapse all match arms in the bridge config builder. Regenerate
flutter_rust_bridge bindings and update Podfile.lock.
2026-06-10 00:51:52 +09:00
Edison Jwa 413f247378 fix(audio): inline VAD worker policy for iOS capture callback
PR #37 inlined VadWorkerPolicy into the desktop capture path but missed
the iOS files. Instead of restoring the deleted types, inline the same
direct if-let-Some pattern into ios_voice_unit.rs (the only remaining
iOS backend) and permanently remove VadWorkerPolicy and
callback_vad_worker_policy from vad/mod.rs.

iOS uses Option<AppleCoreMlVadWorker> with &mut self (no Mutex), so the
inline is simpler than the desktop's try_lock pattern.
2026-06-10 00:47:30 +09:00
Edison Jwa 3f9ea4f7b8 refactor(audio): remove experimental iOS RemoteIO+WebRTC APM path
Delete ios_raw_unit.rs (538 lines) and all SonoraExperimental references
from the core audio crate. The experimental RemoteIO path that bypassed
Apple VPIO in favor of software WebRTC APM was never shipped and is no
longer needed. VPIO is the sole production iOS audio backend.

- Delete ios_raw_unit.rs entirely
- Remove Raw variant from IosVoiceBackend enum in engine.rs
- Remove SonoraExperimental from IosVoiceProcessingMode enum
- Simplify validate_for_ios() (single-variant enum, no mode check)
- Remove 2 SonoraExperimental validation tests
- Remove ios_raw_unit module declaration from lib.rs
- Remove include_str!-based debug_wav test for the deleted file
2026-06-10 00:47:09 +09:00
Edison Jwa 2f6d45fb04 feat(audio): desktop Silero ONNX VAD + Windows PTT modernization + MSVC CRT build fix (#37)
* feat(audio): add Silero ONNX VAD with WebRTC fallback

Introduce SileroOnnxVad and SileroOnnxVadWorker for desktop targets. The worker runs Silero v6 ONNX inference on a dedicated thread, accumulating 10 ms frames into the 512-sample 16 kHz input the model expects. Add VadOutput, VoiceActivityDetector trait, and WebRtcFallbackVad to provide a uniform VAD interface with graceful fallback when the ONNX model is unavailable. Wire the new VadBackend variants through AudioProcessingConfig and the snapshot stats so the bridge can report which detector is active.

* feat(audio): integrate desktop VAD worker into capture engine

Wire SileroOnnxVadWorker into the desktop capture path so voice activity can open the transmit gate before encoding. The capture callback now processes all audio through resample, downmix, and VAD unconditionally; transmit_active still gates Opus encoding.

Add new_desktop_audio_processing_state() to construct the config/stats/worker triple, and apply_desktop_vad_backend() to synchronously load or clear the worker on config changes. Override processing_backend to Noop for desktop so bridge diagnostics report the correct backend rather than the iOS-oriented PlatformVoiceProcessing default.

Includes review-driven cleanups: StreamConfig clone to deref per clippy, and a comment explaining why two try_lock calls on silero_vad_worker are structurally necessary (borrow checker requires the policy probe and the fallback path to not share a lock guard because mark_vad_fallback_active takes &mut self).

* fix(audio): modernize Windows PTT to current windows-rs API

Port the Raw Input plus low-level keyboard hook PTT backend to the newer windows-rs patterns: OptionalHandle, Result-returning CreateWindowExW, and None for CallNextHookEx. Replaces the old HHOOK(0) pointer casts. Add deterministic tests for mouse button 4 and 5 press and release driving the gate.

* build(windows): force MSVC release CRT for audiopus cmake builds

audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot use cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults. Point cmake-rs at a small wrapper that injects the policy and cache variables during configure while passing cmake --build, --version, and -E through unchanged. Keeps Opus Debug builds on Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols at test link.

Document that the iOS deployment target is intentionally absent from this file. It is enforced by tools/build-ios.sh and the Xcode project; setting it globally here would make native macOS cargo check runs try to link iPhone objects against the macOS SDK.

* build(flutter): update pubspec.lock after plugin additions

Regenerated lockfile reflecting the local_notifications and connectivity_plus plugin additions from the poke-notifications feature.

* fix(audio): address PR #37 review findings

Six fixes from independent PR review:

1. BLOCKER: Replace Windows-only cmake .cmd wrapper with cross-platform
   CMake env vars. Setting CMAKE=tools/cmake-msvc-release-crt.cmd
   globally broke non-Windows hosts because cmake-rs would try to
   execute a .cmd file on macOS/Linux. Instead, set
   CMAKE_POLICY_DEFAULT_CMP0091=NEW and CMAKE_MSVC_RUNTIME_LIBRARY=
   MultiThreadedDLL as env vars that CMake reads natively. MSVC-
   specific vars are safely ignored by GCC/Clang toolchains. Delete
   the now-unnecessary wrapper script.

2. IMPORTANT: Join the Silero worker thread in Drop instead of
   detaching it. The old code dropped the JoinHandle which detaches
   the thread; the new code calls handle.join() after closing the
   channel, ensuring the ONNX session is cleaned up before the
   worker is replaced during config changes.

3. IMPORTANT: Single-try_lock refactor of the capture VAD callback.
   The double try_lock (policy probe + send) is replaced by a single
   scoped try_lock that both probes availability and sends the frame.
   The guard is dropped before the fallback path, which needs &mut
   self for mark_vad_fallback_active. This also eliminates the
   VadWorkerPolicy enum and callback_vad_worker_policy function,
   whose behavior is now inlined into the callback.

4. IMPORTANT: Remove tracing from the realtime capture callback.
   mark_vad_fallback_active and sync_vad_backend emitted info!/warn!
   from the audio thread. Replace with silent atomic state
   publishing via SharedAudioProcessingStats; the bridge stats
   stream already exposes vad_fallback_active for diagnostics.

5. IMPORTANT: Defer ONNX model load outside the worker mutex.
   apply_desktop_vad_backend_to_worker now constructs the new worker
   before taking the lock, then swaps it in under a short hold.
   This prevents the realtime callback from being blocked during
   model I/O + thread spawn.

6. MINOR: Remove unused VadBackend import from vad/mod.rs after
   deleting the policy code.

* fix(audio): address PR #37 second-pass review findings

5-agent review found 5 blocking issues. All addressed:

1. BLOCKER: CMake env vars don't reach CMake cache. Restored .cmd wrapper
   but scoped to Windows MSVC targets only via [target.x86_64-pc-windows-msvc]
   and [target.aarch64-pc-windows-msvc] in .cargo/config.toml. Non-Windows
   hosts are unaffected.

2. BLOCKER: processing_backend normalized in set_audio_processing_config
   on desktop (cfg-gated override to Noop), mirroring startup default.

3. BLOCKER: Model-path reload was already wired via reload_audio_processing_config.
   Fixed misleading doc comment in core/lib.rs.

4. BLOCKER: DEC-030 updated to reflect desktop VoiceActivity enablement.
   Traceability docs (SRS, SysDes, SAD, SDD, implementation-status) updated.

5. Silero ONNX cfg narrowed to desktop-only (excludes macOS/Android).
   Cargo.toml ort dependency target cfg narrowed similarly.

6. Realtime callback debt documented as TODO at CaptureState::ingest.

* fix(audio): exclude ort dep on Android target

ort does not provide first-class Android prebuilts in our pin, mirror the
iOS/macOS exclusion so cargo metadata succeeds for android targets.

* test(audio): fix stale select_ptt_backend import in ptt_privacy

The helper moved out of the ptt_backends submodule onto the crate root;
update the integration test imports so the test compiles again.

* build(windows): scope MSVC release CRT cmake wrapper via Cargo [env]

Cargo's [target.<triple>] table only forwards a fixed allowlist
(linker, runner, rustflags, rustdocflags, ar), so setting CMAKE there
was silently dropped and audiopus_sys kept linking the debug CRT,
producing LNK4098 'MSVCRTD conflicts' and __imp__CrtDbgReportW errors
on x86_64-pc-windows-msvc test builds.

Move the override to Cargo's [env] table using cc/cmake-rs's
target-suffixed CMAKE_<triple> lookup (force=true, relative=true) so it
applies to MSVC targets only and not to host tooling. Add stdout
markers to the wrapper so its invocation is provable in cargo -vv logs.

Verified: cargo test -p chanora_audio --target x86_64-pc-windows-msvc
--lib --no-run now links cleanly; CMakeCache.txt records
CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL and CMP0091=NEW.

* fix(flutter): gate VoiceActivity transmit mode by platform support

VoiceActivity relies on the native VAD worker, which is only wired up
on Windows, Linux, and Android. Showing the option on iOS, macOS, or
web let users select a mode that silently never transmitted.

Add voiceActivityTransmitAvailable + transmitModeSegmentsFor() helpers
in voice_settings_controls.dart, hide the VAD row in voice_compact.dart
and drop the VAD segment from the settings dialog when unsupported.
Keep the legacy const transmitModeSegments for the existing widget test
and add two new tests covering the gated helper.
2026-06-09 20:47:16 +09:00
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 ef14c22300 Merge pull request #36 from EdisonJwa/docs/poke-without-message-design
Allow pokes without messages
2026-06-09 09:33:58 +09:00
Edison Jwa b841d3f3e4 docs(security): refresh license inventories 2026-06-09 07:30:23 +09:00
Edison Jwa eca77ece81 fix(chat): allow empty poke messages 2026-06-09 01:47:23 +09:00
Edison Jwa 3462de1eee fix(core): allow empty poke dispatch 2026-06-09 01:44:03 +09:00
Edison Jwa f66118f5bb docs: add poke-without-message design 2026-06-09 00:53:14 +09:00
Edison Jwa 3ef540ae37 Merge pull request #35 from EdisonJwa/feat/poke-notifications
feat: add poke notifications
2026-06-08 23:52:13 +09:00
Edison Jwa e0edcc89ac Merge pull request #34 from EdisonJwa/simplify-project-review
Maintainability continuation and Android smoke evidence
2026-06-08 23:46:37 +09:00
Edison Jwa 7922eabcf0 build(android): keep notification icon resource 2026-06-08 23:38:55 +09:00
Edison Jwa 82bfa0ea1f docs: remove trailing whitespace from continuation design 2026-06-08 23:36:05 +09:00
Edison Jwa dc52092654 docs: remove trailing whitespace from continuation design 2026-06-08 23:32:18 +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 cf64274fe6 feat(bridge): expose poke strength to Flutter 2026-06-08 22:52:33 +09:00
Edison Jwa 5c7a4b64c9 feat(core): propagate poke strength events 2026-06-08 22:52:08 +09:00
Edison Jwa 31cf45ce35 feat(protocol): classify poke notification strength 2026-06-08 22:51:41 +09:00
Edison Jwa a644770488 docs: record Android verification evidence 2026-06-08 21:26:20 +09:00
Edison Jwa 5a1d902795 fix(audio): migrate Android JNI paths 2026-06-08 21:24:16 +09:00
Edison Jwa 901369b072 docs: align core split verification notes 2026-06-08 20:20:48 +09:00
Edison Jwa 57a4d9767b refactor: align bridge state resolver metadata 2026-06-08 20:20:18 +09:00
Edison Jwa 8c4f85ee70 refactor: reuse built-ins and shared helpers 2026-06-08 20:19:17 +09:00
Edison Jwa 7c341d42e5 fix(core,protocol): bound disconnect shutdown 2026-06-08 20:15:50 +09:00
Edison Jwa 8487acf167 docs: align review findings and verification gates 2026-06-08 19:52:16 +09:00
Edison Jwa 8606eb48c8 fix(audio): harden realtime callback paths 2026-06-08 19:40:03 +09:00
Edison Jwa d83539436e fix(voice): preserve mute owners and release touch ptt 2026-06-08 18:00:32 +09:00
Edison Jwa e4fdf8414a docs: add maintainability continuation plan 2026-06-08 17:04:29 +09:00
Edison Jwa 0f41993ed0 docs: add maintainability continuation design 2026-06-08 17:01:59 +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 76fe8faa94 fix(audio,ios): gate output_underrun on !muted in refactored render callback
The PR #27 refactor moved the iOS render callback to a direct-fill
path with its own peak_i16 == 0 check, dropping the !muted gate
that PR #28 added to the pre-refactor callback. Without this
gate, every muted callback fires a false-positive
increment_output_underrun() because the downmix helper writes
silence (peak_i16 = 0) by design when muted.

Re-apply PR #28's gate to the refactored iOS path so this PR does
not silently reintroduce the bug PR #28 was opened to fix.

Verified:
- cargo test -p chanora_audio --lib: 133 passed, 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
2026-06-07 23:27:46 +09:00
Edison Jwa 5257b1e3ac chore(repo): gitignore .omo/ session directory 2026-06-07 23:27:46 +09:00
Edison Jwa 0d37550d43 chore(licenses): update inventory for windows-core 0.54 → 0.62 bump
Inventory regenerated after refactor(audio): share AudioHandler between iOS and macOS, bump deps (6214139), which dropped windows-core 0.54.0 (and its windows-result 0.1.2 transitive dep). Apache-2.0 crate count drops from 333 to 327. No license-class change.
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 581353b6d2 docs(sdd): document VoiceActivity gate, macOS render cadence, iOS render cadence
docs/architecture/sdd.md: three new rows in the Audio Detailed Design table. 'VoiceActivity gate (capture-side)' documents voice_activity::VoiceActivityStateMachine — the 10 ms-cadence gate for TransmitMode::VoiceActivity with open-after (40 ms) / hangover (500 ms) / min-tx (200 ms) / weak-hold (30-100 frames) timers, plus live configure() re-clamping behaviour. 'macOS render cadence (producer + ring)' documents the 20 ms tokio producer task + crossbeam ArrayQueue ring with 100 ms prebuffer, the design chosen to decouple ingress quantums (20 ms Opus frames) from egress quantums (whatever VPIO asks for). 'iOS render cadence (direct-fill)' documents the direct-fill callback path with preallocated 4096x2 f32 scratch buffer and try_lock semantics (not blocking lock).

Closes the VoiceActivity / macOS-producer-ring / iOS-direct-fill doc gaps flagged in the PR #27 'Deferred' list.
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 d59da05f93 refactor(audio): share AudioHandler between iOS and macOS, bump deps
crates/chanora_audio/src/engine.rs: drop the macOS-specific event-queue producer/consumer path; macOS now uses the iOS-style direct AudioHandler::fill_buffer in the VPIO render callback. The shared AudioHandler is an Arc<Mutex<...>>; the realtime callback uses try_lock so it never blocks on the tokio decode task (see ios_voice_unit.rs render callback).

crates/chanora_audio/src/mobile_voice_backend.rs: update VoiceAudioParams cfg gates — handler is now the iOS/macOS/desktop shape (Arc<Mutex<AudioHandler<SessionAudioId>>>), event_producer is Android-only.

crates/chanora_audio/src/lib.rs: widen the audio_event_queue module visibility to test so the macOS-specific path can be exercised by the unit test suite.

Cargo.toml: bump cpal 0.17.3 -> 0.18.0, jni 0.21 -> 0.22.4, windows 0.54 -> 0.62, criterion 0.5 -> 0.8. Cargo.lock follows.
2026-06-07 23:27:46 +09:00
Edison Jwa eb10db5b59 docs(audio): document macOS 13 floor, render peak limiter, and VPIO ducking config
- docs/sysrs.md: raised macOS minimum runtime in SysRS-310 from 10.15 to 13.0 to match the actual floor in apps/chanora_flutter/macos/chanora_bridge.podspec (MACOSX_DEPLOYMENT_TARGET = 13.0) and macos_deployment_target.rb; added a change-log entry for the raise. SysRS-051 gained a note documenting the iOS/macOS audio-lifecycle asymmetry (iOS has full AVAudioSession lifecycle; macOS is limited to launch-time mic permission + VPIO engine restart on default-device change + VPIO startup readback in the current baseline).

- docs/architecture/sdd.md: added two rows to the Audio Detailed Design table. 'Render peak limiter' documents voice_render::limit_peak_inplace (single-pass, allocation-free, threshold 0.99, applied in both Apple render callbacks before i16 downmix). 'VPIO ducking config (macOS 14+)' documents the 8-byte AuVoiceIoOtherAudioDuckingConfiguration struct write to selector 2108 on the VoiceProcessingIO AudioUnit at startup, with the macOS 13 silent-fallback behaviour.

- docs/governance/product-decision-register.md: added DEC-033 recording the VPIO ducking configuration decision (advanced ducking off, level = Min, macOS 14+ only).
2026-06-07 23:27:46 +09:00
Edison Jwa 74951dd7b4 fix(audio,macos): render-path peak limiter
voice_render.rs: new limit_peak_inplace helper (single-pass, allocation-free peak scaler) with 4 unit tests. Applied in both the macOS and iOS render callbacks before the i16 conversion to prevent hard clipping on multi-client mixes that sum past 0 dBFS. Threshold 0.99 keeps the limiter transparent for normal voice levels (sub-millisecond per-frame latency at 48 kHz; pumping risk negligible for speech).

ios_voice_unit.rs: limit_peak_inplace call sites added to the macOS producer/ring render callback (per-frame, 2-element stack array) and the iOS direct-fill_buffer render callback (per-callback, on the scratch_stereo buffer); the downmix helper's hard-clamp is kept as defense-in-depth and is not expected to engage in the integrated flow.
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 957d68f39d fix(audio,ios): gate output_underrun on !muted
ios_voice_unit.rs:1033: change the condition from `mix_stats.peak_i16 == 0` to `mix_stats.peak_i16 == 0 && !muted`. When the user mutes the channel via output_muted, the downmix helper fills the output buffer with silence (peak = 0), which previously falsely incremented the output_underrun counter. The mute toggle is intentional silence, not a real underrun.

Note: this does not address the separate false positive where peak_i16 == 0 with output unmuted but no audio incoming (e.g., just joined a channel with no remote speaking). A complete fix would require tracking whether the audio handler actually produced data; deferred to a follow-up.
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 2902a8bcd5 fix(audio): eliminate Android output stutter via Oboe config + lock-free callback (#20)
* fix(audio): eliminate Android output stutter via Oboe config + lock-free callback

Phase 1 — Oboe configuration:
- Change output stream from Usage::VoiceCommunication to Usage::Game with
  ContentType::Sonification to avoid forcing the Legacy (OpenSL ES) data
  path on most devices (Oboe issue #2075)
- Switch output format from i16 Mono to f32 Stereo, matching Qint's proven
  configuration and eliminating per-callback downmix conversion
- Set buffer size to 2x burst after stream open, reducing default buffer
  from 8-20x burst to 2x burst for lower latency
- Remove scratch Mutex<Vec<f32>>; callback writes directly to Oboe buffer

Phase 2 — Lock-free output callback:
- Add audio_event_queue.rs: lock-free SPSC bridge using crossbeam ArrayQueue
  with separate packet (lossy) and control (reliable) channels
- OutputCallback now owns AudioHandler directly (no Arc<Mutex<>> on Android)
- Inbound forwarder pushes packets via AudioEventProducer (no mutex)
- set_client_volume pushes control commands via event queue on Android
- iOS/desktop Arc<Mutex<AudioHandler>> path unchanged

* fix(audio): address PR #20 review findings

- Store AudioEventConsumer directly in OutputCallback to eliminate
  per-callback Arc clone on the real-time audio thread
- Add SAFETY comment for the unsafe from_raw_parts_mut transmute
- Bound set_client_volume spin-loop to 64 retries with warn log
- Remove redundant crossbeam-utils direct dependency
- Regenerate license inventory for new crossbeam deps (CI fix)

* fix(audio): use ASCII TODO punctuation
2026-06-05 13:57:53 +09:00
Edison Jwa fb2a8e0a80 docs(rust): add doc comments to delta enums and fix dead_code warnings (#24)
* docs(rust): add doc comments to delta enums and fix dead_code warnings

Add missing documentation to ProtocolDelta, CoreDelta, and BridgeDelta
enum variants and their struct fields across the protocol, core, and
bridge crates. Document the ChannelId::ROOT constant and the
take_delta_rx adapter method.

Fix dead_code warnings:
- keyring_disabled: add #[cfg] gate matching its callers
- snapshot_signature: add #[cfg(test)] for future test use

* fix(rust): correct `order` field docs to predecessor channel ID, narrow audio engine cfg gates

- Correct `order` field documentation in ProtocolDelta, SessionEvent,
  and BridgeEvent from 'sort order' to 'predecessor channel ID
  (TeamSpeak linked-list ordering hint)' per Copilot review feedback.
- Narrow AudioEngine voice_out_tx, voice_activity_selector, and mic_gain
  cfg gates from ios+macos+android to android-only, since these fields
  are only read from self in android_restart_voice_unit. On iOS/macOS the
  values are passed directly to the voice backend at construction time.
2026-06-05 11:54:59 +09:00
Edison Jwa d3bb199208 ci: add opencode GitHub Actions workflow 2026-06-03 19:28:06 +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 dab90852a3 Merge pull request #13 from EdisonJwa/feat/apple-coreml-vad
Add Apple CoreML Silero VAD
2026-06-02 20:14:17 +09:00
Edison Jwa 1813bbaa0c fix(ci): keep benchmark advisory non-blocking 2026-06-02 20:01:58 +09:00
Edison Jwa da174806ac fix(ci): install SDL2 for Rust tests 2026-06-02 19:52:07 +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 ddf858cc6c fix(audio): address CoreML VAD review feedback 2026-06-02 19:39:11 +09:00
Edison Jwa 966afd2b53 ci: fix Apple CoreML VAD checks 2026-06-02 19:33:02 +09:00
Edison Jwa 96aa943d5f Merge pull request #12 from EdisonJwa/chore/rename-prefetch-crate-pr
chore(prefetch): rename server prefetch crate
2026-06-02 14:12:24 +09:00
Edison Jwa 0e6c4941ad docs(security): refresh license inventory ordering 2026-06-02 14:07:57 +09:00
Edison Jwa 1fc0aaeabb docs(prefetch): update rename design spec 2026-06-02 13:34:19 +09:00
Edison Jwa d19501ec66 docs(prefetch): update rename implementation plan 2026-06-02 13:34:12 +09:00
Edison Jwa 92c09aece3 docs(verification): rename prefetch crate references 2026-06-02 13:34:06 +09:00
Edison Jwa 8506e56da1 docs(security): rename prefetch crate references 2026-06-02 13:33:59 +09:00
Edison Jwa 31373d3ec4 docs(architecture): rename prefetch crate references 2026-06-02 13:33:53 +09:00
Edison Jwa 053c248ec4 refactor(core): use renamed prefetch crate 2026-06-02 13:33:47 +09:00
Edison Jwa 7d47a1e14b chore(prefetch): rename workspace crate 2026-06-02 13:33:41 +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
Edison Jwa 813a38e92b feat(audio): add Apple CoreML Silero VAD 2026-06-02 01:52:08 +09:00
Edison Jwa 71b7502ab7 Merge pull request #11 from EdisonJwa/fix/reduce-voice-snapshot-polling
perf(ui): stop polling snapshots from audio stats
2026-05-31 23:44:35 +09:00
Edison Jwa 25e6a16eb1 Merge pull request #10 from EdisonJwa/fix/ios-audio-lifecycle-lag
fix(ios): stabilize audio lifecycle startup
2026-05-31 23:43:32 +09:00
Edison Jwa 7fb89c3dc7 Merge pull request #8 from EdisonJwa/fix/storage-test-isolation
Isolate storage test temp directories
2026-05-31 23:42:50 +09:00
Edison Jwa a4f6d66aec Merge pull request #7 from EdisonJwa/salvage/app-snack-bar
Add shared app snackbar styling
2026-05-31 23:42:18 +09:00
Edison Jwa 714a6b758c perf(ui): stop polling snapshots from audio stats 2026-05-31 23:03:19 +09:00
Edison Jwa ecb9ae9636 fix(audio): restart iOS voice unit in place 2026-05-31 22:30:56 +09:00
Edison Jwa e8e9fa8ccf build(ios): remove onnxruntime pod wiring 2026-05-31 22:30:46 +09:00
Edison Jwa 6f7063971d fix(audio): use WebRTC VAD on iOS 2026-05-31 22:30:28 +09:00
Edison Jwa c02d4e6df3 fix(bridge): serialize iOS audio lifecycle events 2026-05-31 22:29:38 +09:00
Edison Jwa d39d6c78d2 fix(core): unblock iOS connect audio startup 2026-05-31 22:29:30 +09:00
Edison Jwa 681f3b636f test(storage): isolate temp directories in tests 2026-05-29 19:19:20 +09:00
Edison Jwa b320f24c93 feat(ui): add shared app snackbar styling 2026-05-29 18:40:57 +09:00
Edison Jwa 0e04dac064 Restore product scaffold to rollback baseline
Merge reset-style baseline PR after local verification. GitHub Actions did not start because of the account billing/spending-limit blocker documented in the PR body.
2026-05-29 16:13:46 +09:00
Edison Jwa fe6e07353e chore: restore product scaffold to rollback baseline 2026-05-29 14:02:04 +09:00
Edison Jwa 2896f14ec9 fix: show one linux audio backend 2026-05-25 18:31:20 +09:00
Edison Jwa 487aff4e7c fix: improve voice and chat controls 2026-05-25 18:26:56 +09:00
Edison Jwa d03ea937e6 fix: refine linux voice runtime behavior 2026-05-25 18:26:49 +09:00
Edison Jwa cd14aa7b98 build: bundle onnxruntime in linux releases 2026-05-25 18:26:40 +09:00
Edison Jwa a2d686d9d0 feat: promote linux native audio path 2026-05-25 17:42:06 +09:00
Edison Jwa c19de3a370 chore: ignore local third party experiments 2026-05-25 16:20:50 +09:00
Edison Jwa c502a4dd00 chore: add local dev toolchain environment 2026-05-25 16:11:32 +09:00
Edison Jwa afd8e525f9 fix: use matching flutter linux bundle arch 2026-05-25 16:04:37 +09:00
Edison Jwa 862f83b297 fix: parse linux distro metadata 2026-05-25 16:02:01 +09:00
Edison Jwa 4a2a05ef83 fix: keep diagnostics available while connecting 2026-05-25 16:01:48 +09:00
Edison Jwa b8df25a195 Reduce Linux setup ambiguity and surface desktop input/message failures honestly
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful.

Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies
Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped
Confidence: medium
Scope-risk: moderate
Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance
Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib
Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
2026-05-25 11:55:10 +09:00
Edison Jwa 5c8c6df16b Make beta release artifacts reproducible on Linux and Android
Constraint: SRS-118 and SRS-119 require a Linux release package and an Android release AAB, and the current workspace also needs the sibling oboe-rs checkout for Cargo manifest loading.
Rejected: Keep release packaging as ad-hoc local knowledge | CI and contributors would still miss the required artifacts and hit the missing oboe-rs prerequisite.
Confidence: medium
Scope-risk: moderate
Directive: If the oboe-rs fork path changes or is vendored, update the helper scripts and workflow checkout steps together.
Tested: bash -n tools/build-linux-deb.sh tools/build-android-aab.sh; python3 YAML parse for .github/workflows/ci.yml, .github/workflows/bench-advisory.yml, .github/workflows/bench-baseline-update.yml; git diff --check
Not-tested: End-to-end flutter build linux --release; end-to-end flutter build appbundle --release; GitHub Actions runtime execution
2026-05-25 03:21:21 +09:00
Edison Jwa 1709ae2ac2 Route Android system back through the Flutter shell
Constraint: SRS-163 requires Android back to be handled by the shell/platform layer, and the Dart service existed but was not wired into the live app.
Rejected: Leave BackIntentService test-only and unwired | System back would bypass app policy on Android.
Confidence: medium
Scope-risk: narrow
Directive: Keep back-intent probe state in sync with every new dialog or pushed route added to the Flutter shell.
Tested: dart format apps/chanora_flutter/lib/main.dart; flutter analyze lib/main.dart test/services/back_intent_service_test.dart; flutter test test/services/back_intent_service_test.dart
Not-tested: Manual Android device back-navigation smoke test
2026-05-25 03:21:02 +09:00
Edison Jwa 0648203100 build: lock resolver dependencies 2026-05-25 02:58:53 +09:00
Edison Jwa 6f64062fd8 chore: clean Android voice build warnings 2026-05-25 01:27:28 +09:00
Edison Jwa 7c9660572e chore: refresh iOS project metadata 2026-05-25 01:20:36 +09:00
Edison Jwa 1326b03301 build: add Android release APK packaging script 2026-05-25 01:20:16 +09:00
Edison Jwa 5515ff6643 feat: stabilize voice activity and audio routing 2026-05-25 01:19:09 +09:00
Edison Jwa eb9014cd81 feat: add TeamSpeak address resolver 2026-05-25 01:12:50 +09:00
Edison Jwa d7556cd39f feat: request Android startup permissions 2026-05-25 01:10:10 +09:00
Edison Jwa 11d4e7b7da fix: stabilize Android permission state names 2026-05-25 01:08:02 +09:00
Edison Jwa b8195acc9b perf: avoid duplicate bootstrap work on join 2026-05-24 21:02:16 +09:00
Edison Jwa b494f8902d perf: short-circuit granted mic permission checks 2026-05-24 20:54:49 +09:00
Edison Jwa 9b8d812f9c chore: ignore local workspace metadata 2026-05-23 06:52:48 +09:00
Edison Jwa 3200312b0d docs: add baseline references and workspace tasks 2026-05-23 06:52:24 +09:00
Edison Jwa 7d5d8c2c90 feat: integrate chat voice and diagnostics client 2026-05-23 06:51:55 +09:00
Edison Jwa 7e28791ec2 Handle pokes outside chat tabs 2026-05-23 05:25:36 +09:00
Edison Jwa 738b274748 chore: bump build number to +76 2026-05-22 10:03:17 +09:00
Edison Jwa bf284018e6 feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
2026-05-22 09:29:57 +09:00
Edison Jwa 6af4ecab0f feat(voice): add iOS VAD runtime support 2026-05-21 20:51:45 +09:00
Edison Jwa 171baf6e41 fix(voice): clamp transmit when speakers muted 2026-05-20 16:37:48 +09:00
Edison Jwa 9a2c882ab0 fix(core): keep speaker mute local 2026-05-20 16:26:19 +09:00
Edison Jwa f8a9c7a422 fix(android): add output device picker 2026-05-20 16:26:13 +09:00
Edison Jwa f4db27b565 fix(android): expose audio output devices 2026-05-20 16:26:07 +09:00
Edison Jwa 2382c2b569 fix(security): update vulnerable rust dependencies 2026-05-20 14:52:34 +09:00
Edison Jwa 256874997b fix(android): request microphone permission on startup 2026-05-20 14:52:34 +09:00
Edison Jwa b65cedfa95 fix(android): recover from input stream failures 2026-05-20 14:52:34 +09:00
Edison Jwa bc1bd89424 fix(android): strengthen user voice indicators 2026-05-20 14:52:34 +09:00
Edison Jwa b21fc16ee6 fix(android): clarify channel and voice status icons 2026-05-20 14:52:34 +09:00
Edison Jwa 34121b2eb7 fix(android): use row-only channel joins 2026-05-20 14:52:34 +09:00
Edison Jwa 48a38dba46 fix(android): prompt from locked channel rows 2026-05-20 14:52:34 +09:00
Edison Jwa f8b6485390 fix(android): show password action only for locked channels 2026-05-20 14:52:34 +09:00
Edison Jwa 273eaf21c1 fix(android): add channel password join action 2026-05-20 14:52:34 +09:00
Edison Jwa 42d0ca0953 fix(android): polish app chrome and join guard 2026-05-20 14:52:34 +09:00
Edison Jwa bc70da51df fix(android): derive current channel from snapshot 2026-05-20 14:52:34 +09:00
Edison Jwa 7473878136 fix(core): emit voice state from snapshots 2026-05-20 14:52:34 +09:00
Edison Jwa 3ae8e1ab77 fix(android): adapt fold layout and diagnostics 2026-05-20 14:52:34 +09:00
Edison Jwa 30ff955439 fix(android): clear permission-owned hard mute 2026-05-20 14:52:34 +09:00
Edison Jwa eb1f4b895e fix(android): remove cpal product wording 2026-05-20 14:52:34 +09:00
Edison Jwa 210a6a0e11 fix(android): unblock mic permission startup 2026-05-20 14:52:34 +09:00
Edison Jwa 7254f81c65 fix(core): complete voice joins on server accept 2026-05-20 14:52:33 +09:00
Edison Jwa 2861f5b010 fix(core): normalize channel passwords before joins 2026-05-20 14:52:33 +09:00
Edison Jwa 26b06ce786 fix(android): expose voice diagnostics safely 2026-05-20 14:52:33 +09:00
Edison Jwa d6763787aa feat(android): initialise Oboe runtime context 2026-05-20 14:52:33 +09:00
Edison Jwa c87b47f064 feat(audio): prefer native voice backends 2026-05-20 14:52:33 +09:00
Edison Jwa 7d6d56e330 docs(p0): compact MVP spec for Android Oboe focus 2026-05-20 14:52:33 +09:00
Edison Jwa 8c253f1d4d feat(voice): harden Android audio and channel joins 2026-05-19 01:58:07 +09:00
EdisonJwa 29a553d4e1 docs(traceability): add Deferred Work Watchlist (DW-001..DW-012); matrix v0.9.11
Convert prose-only deferrals scattered across SDD-120 §10, SDD-119 §3, DEC-032 resolution prose, and researcher Wave 4 Tier B notes into a single durable governance register. Watchlist is governance-layer only; no engineering-chain coverage rule relaxed.

Entries:

- DW-001 Dimension 3 production telemetry export (SDD-120 §10 / SysRS-308; P1; systems-requirements)

- DW-002 Build-failing hard CI gate vs. current advisory-only (SDD-120 §10; P1; systems-requirements)

- DW-003 Multi-host benchmarking (macOS Apple Silicon + Windows x86_64; SDD-120 §10 / SysDes-157; P2; system-architect)

- DW-004 IDE integration for cargo bench (SDD-120 §10; P3; builder)

- DW-005 Dart-side flutter_rust_bridge round-trip bench (SDD-120 §10 / Tier B4; P2; detailed-designer)

- DW-006 iOS deployment-target SoT consolidation (SDD-119 §3 v0.9.17; macOS half already closed by b23b46c; P2; detailed-designer)

- DW-007 Opus encode/decode latency under varied conditions (Tier B; P2; verification-engineer)

- DW-008 Resampler throughput at non-canonical rate pairs (Tier B; P3; verification-engineer)

- DW-009 Protocol forwarder loop tracing benchmark (Tier B; P3; verification-engineer)

- DW-010 Remove [patch.crates-io] cmake-rs pin once PR #257 merges and a release lands (DEC-032 resolution prose; P2; builder)

- DW-011 Linux SIGABRT root cause + graceful shutdown SRS/SDD chain (no source SDD yet; P0; debugger then systems-requirements)

- DW-012 Manual bench-baseline-update.yml workflow_dispatch to seed first real baseline (SDD-120 §6 / SAD-091; P1; operator action)

DW-010 and DW-006 had partial prose precedents in DEC-032 and SDD-119 respectively; neither was tracked in a watchlist. No duplicates introduced. ID convention (monotonic DW-NNN, retired-not-reused) established in the section intro. Doc-only change; no code touched.
2026-05-18 14:52:01 +08:00
EdisonJwa 477394d83e fix(android,build): restore multi-ABI build via cmake-rs patch (DEC-032 exit)
Closes DEC-032. Restores the canonical Android ABI set
{arm64-v8a, armeabi-v7a, x86_64} per SDD-073 item 4 / SDD-118 item 3.

Root cause was the audiopus_sys + cmake-rs + NDK toolchain-file gap:
cargo-ndk 4.x sets ANDROID_ABI / ANDROID_PLATFORM as env vars per
invocation, but upstream cmake-rs 0.x does not forward them to the
child cmake invocation as -D variables, so armeabi-v7a and x86_64
configure steps fell through to the toolchain-file default and
failed to build.

Fix:
- Cargo.toml: add a workspace [patch.crates-io] stanza pinning the
  cmake crate to fork pr2502/cmake-rs @ commit
  bdad5edc569d82151922c5c6c4685b1563f12aa1 (branch android-build),
  which carries cmake-rs PR #257
  (https://github.com/rust-lang/cmake-rs/pull/257). The patch is a
  9-line addition that forwards ANDROID_ABI and ANDROID_PLATFORM
  from the env to the child cmake as -D variables.
- Cargo.lock: regenerated by 'cargo update -p cmake'; the lone
  cmake entry now points at the fork rev.
- apps/chanora_flutter/android/app/build.gradle.kts: restore
  abiFilters to {arm64-v8a, armeabi-v7a, x86_64}; remove the
  TODO(x86_64/armv7 follow-up) comment.
- docs/governance/product-decision-register.md: mark DEC-032 as
  Resolved (2026-05-18) with the resolution mechanism, update the
  §3 / §7 rows, and append a 0.9.8.1 change-history entry.

Verification (host: Linux):
  cargo update -p cmake                          -> pulled fork rev
  cargo check --workspace --all-targets          -> PASS
  cargo test --workspace                         -> PASS (no regressions)
  cargo ndk --platform 28 -t arm64-v8a build -p chanora_bridge   -> PASS
  cargo ndk --platform 28 -t armeabi-v7a build -p chanora_bridge -> PASS
  cargo ndk --platform 28 -t x86_64 build -p chanora_bridge      -> PASS

Upstream tracking: re-evaluate the [patch.crates-io] override once
cmake-rs PR #257 merges and a fresh cmake release lands on
crates.io; at that point switch to a plain dep bump and remove the
override.
2026-05-18 14:48:16 +08:00
EdisonJwa 167aef5760 docs(sysrs): SysRS-310 — macOS minimum runtime baseline (v0.9.11)
Author SysRS-310 ratifying the macOS minimum runtime baseline at 10.15
(Catalina) at the SysRS layer, parallel to SysRS-286 (iOS 13.0) and
SysRS-288 (Android API 28).

Closes the Wave 1.5 traceability-audit deferred-but-optional follow-up.
ID allocation: SysRS-310 (not SysRS-290, which is already allocated to
MVP single-active-server-connection scope); monotonic numbering
preserved.

Cross-references SAD-087, SDD-119, SysRS-286, SysRS-288. Verification:
Review + Platform Test on a macOS 10.15 system. Raising the baseline
(e.g., to 11.0 / Big Sur) requires a DEC entry.

Trace: SysRS v0.9.11, SAD-087, SDD-119.
2026-05-18 14:41:55 +08:00
EdisonJwa a77a9b2ef2 docs(sdd-119)+feat(macos): consolidate deployment-target SoT (SDD-119 amendment v0.9.17)
Resolve the macOS half of the SDD-119 item 3 single-source-of-truth follow-up. The chanora_bridge cdylib macOS deployment-target floor ('10.15') was previously hard-coded at 7 sites across Podfile and chanora_bridge.podspec; this commit collapses them to a single Ruby constant declaration in a new SoT file.

Selected Option B (Ruby constant) over Option A (.xcconfig — rejected because the podspec prepare_command runs before any xcconfig is applied) and Option C (versioned text file — rejected as overkill given both consumers are already Ruby). Realizes SAD-087(a)'s 'single macOS build-configuration location' mandate.

Out of scope: the pbxproj 'MACOSX_DEPLOYMENT_TARGET = 10.15' lines (565/666/717) belong to the PBXProject default config and are independently overridden to '11.0' at the Runner PBXNativeTarget level; they are the Runner app's floor, not the bridge cdylib's floor. The iOS half (IPHONEOS_DEPLOYMENT_TARGET=13.0) remains an open follow-up.

Files: new apps/chanora_flutter/macos/macos_deployment_target.rb (MACOS_BRIDGE_DEPLOYMENT_TARGET = '10.15'.freeze); Podfile + chanora_bridge.podspec require_relative the constant and consume it at 7 sites; docs/architecture/sdd.md SDD-119 item 3 rewritten + Notes bullet updated + v0.9.17 changelog entry.
2026-05-18 14:39:14 +08:00
EdisonJwa 92087d066a docs(verification,traceability): SWE4-UV-058..062 for SDD-120 benches + matrix absorption
Closes the SDD-120 §11 verification-engineer follow-up and refreshes the
traceability matrix to incorporate the full benchmark-infrastructure
chain landed in commits 3a7750a / 8e95972 / 75b04f0.

swe4-unit-verification-plan.md v0.9.14 → v0.9.15:
- SWE4-UV-058: bench_capture_alloc_count (SDD-120 §3; SRS-216 metric 1;
  SRS-219 clause a zero-tolerance).
- SWE4-UV-059: bench_capture_callback_wall_clock (metric 2; SRS-219 b
  +20% p95).
- SWE4-UV-060: bench_opus_encode_latency (metric 3; SRS-219 c +15% mean).
- SWE4-UV-061: bench_opus_decode_latency (metric 4; SRS-219 c +15% mean).
- SWE4-UV-062: bench_resampler_throughput (metric 5; SRS-219 d -10%
  samples/sec).
All five status PENDING_BASELINE until the first manual
bench-baseline-update.yml dispatch establishes baselines.

traceability-matrix.md v0.9.9 → v0.9.10:
- 9 new chain rows binding SysRS-307..309 → SysDes-156..158 →
  SRS-216..219 → SAD-088..091 → SDD-120 sections → code anchors →
  SWE4-UV-058..062.
- 10 new code-anchor rows for the benchmark files (benches/{common,
  realtime_capture,opus_codec,resampler}.rs + examples/{emit,compare}
  _baseline.rs + Cargo.toml + bench-advisory.yml +
  bench-baseline-update.yml + .gitignore).
- 5 §A markers reconciled to PENDING_BASELINE (the SWE.4 IDs are now
  authored; what remains pending is the baseline measurement).
- DEC-032 (abiFilters reduction) status unchanged; exit criteria
  pending.
- All 5 prior open issues from earlier wave audits confirmed closed.

Verdict: TRACEABILITY_OK (engineering chain closed end-to-end). Only
remaining gap: PENDING_BASELINE, gated on CI minutes return + manual
workflow_dispatch on bench-baseline-update.yml.
2026-05-18 14:15:04 +08:00
EdisonJwa ed3c01bcb9 chore: ignore SDD-120 bench-harness root outputs (current.json, report.md)
Canonical baseline lives at crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
and is updated only via the bench-baseline-update.yml workflow.
2026-05-18 14:05:11 +08:00
EdisonJwa b4cfb8174d docs(sdd-120): correct post-processor binary placement to examples/ (v0.9.16)
SDD-120 amendment v0.9.15 → v0.9.16 reflecting commit 3a7750a discovery.

The two post-processor tools (emit_baseline, compare_baseline) were originally
specified in SDD-120 §2 as residing in crates/chanora_audio/benches/, with
[[bin]] declarations in Cargo.toml. The implementation discovered that Cargo's
dependency resolver only routes [dev-dependencies] to [[test]], [[bench]], and
[[example]] targets — NOT to [[bin]] targets under src/bin/ or to ad-hoc paths.

Routing serde_json (required by both binaries) as a regular [dependencies] entry
to support [[bin]] placement would force it into the production cdylib build,
contradicting SDD-120 §10 release-artifact isolation: "no production telemetry
export" and "no benchmark surface in shipping artifacts." Confirmed by the user's
question about whether benchmark crates ship in the release artifact (they must
not).

The correct Cargo-idiomatic placement is examples/. Cargo auto-discovers files
under examples/ as example targets; they receive [dev-dependencies] routing; and
they are excluded from cargo build --release and from flutter build --release
artifacts by Cargo design.

Amendment scope (SDD-120 only):
- §1 item 4: [[bin]] → [[example]]; cargo run --bin → cargo run --example.
- §2: added item 5 with the rationale clause + Cargo dependency resolver
  explanation.
- §5 item 1: path benches/ → examples/; invocation flag --bin → --example.
- §5 item 4: dev-dependencies routing clause updated.
- §6 step 6/8: --bin → --example.
- §7 step 5: --bin → --example.
- §10 item 1: release-artifact-isolation bullet extended to cite the
  [dev-dependencies] Cargo design mechanism that enforces it.
- §11 verification matrix unchanged.
- "Allocated to" line + "Software units" list updated to reference examples/
  paths.

No semantic change to SDD-120: same harness, same metrics, same workflows, same
out-of-scope deferrals. The implementation at 3a7750a already lives at the
corrected paths; this amendment brings the SDD text into agreement with the
code.
2026-05-18 14:03:18 +08:00
EdisonJwa 7188a5a69d feat(perf,benchmark-infra): criterion bench harness + advisory CI workflows (SDD-120)
Implementation of SDD-120 §1-§8:

Bench harness (crates/chanora_audio/benches/):
- common.rs: deterministic synthetic audio (440 Hz sine, no RNG).
- realtime_capture.rs: bench_capture_alloc_count (dhat) +
  bench_capture_callback_wall_clock (criterion).
- opus_codec.rs: bench_opus_encode_latency + bench_opus_decode_latency
  (direct audiopus, not AudioHandler — SDD-120 §3 item 4).
- resampler.rs: bench_resampler_throughput across 44.1->48 /
  16->48 / 48->48 passthrough.

CI tooling (crates/chanora_audio/examples/):
- emit_baseline.rs: aggregates criterion estimates.json outputs
  into the SRS-217 baseline schema.
- compare_baseline.rs: applies SRS-219 tolerance, renders markdown
  table with 🟢/🟡/🔴 markers + yellow simpler-form realization per
  SDD-120 §8.

  Deviation from SDD-120 §2 / §5 / §7 placement: these tools live
  under examples/, not benches/ or src/bin/. Rationale: they must
  consume serde_json (a dev-only dep — production builds must not
  pull it). Cargo only resolves dev-dependencies for [[test]],
  [[bench]], and [[example]] targets; [[bin]] targets under
  src/bin/ see only regular [dependencies]. examples/ keeps the
  binaries out of the production dep tree while still giving them
  cargo run --example invocation. An SDD-120 amendment should
  reflect this.

Workflows (.github/workflows/):
- bench-advisory.yml: PR + push triggers; runs benches; posts a
  sticky PR comment via actions/github-script@v7; job status is
  always success (SRS-218 clause 4 — non-blocking).
- bench-baseline-update.yml: workflow_dispatch only; runs benches;
  opens PR via peter-evans/create-pull-request@v6 (sole writer of
  the SAD-089 baseline JSON).

Cargo.toml additions ([dev-dependencies] only — verified excluded
from --release builds): criterion 0.5, dhat 0.3, serde_json 1.

Source-code seam: minimal pub-but-#[doc(hidden)] bench_seam module
in chanora_audio (engine.rs + lib.rs re-export) so the criterion
bench harness can construct a CaptureState and drive
CaptureState::ingest without re-implementing the engine (SDD-120
§3). Non-iOS targets only — CaptureState itself is iOS-gated.

Initial baseline seed: crates/chanora_audio/benches/baselines/
x86_64-unknown-linux-gnu.json = {}. compare_baseline handles the
missing-baseline case gracefully and emits a 'no red markers'
report; the first manual dispatch of bench-baseline-update.yml
after merge establishes the real values.

Out of scope per SDD-120 §10: production telemetry export,
build-failing hard CI gate, multi-host benchmarking, IDE
integration, Dart-side bridge round-trip bench.

Verification:
- cargo check --workspace --all-targets: PASS.
- cargo bench --bench realtime_capture --no-run: PASS.
- cargo bench --bench opus_codec --no-run: PASS.
- cargo bench --bench resampler --no-run: PASS.
- cargo build --example emit_baseline --example compare_baseline
  -p chanora_audio: PASS.
- cargo test --workspace: 106 passed, 0 failed, 3 ignored — no
  regression from prior count.
2026-05-18 13:52:15 +08:00
EdisonJwa 575a6cbc5c docs(perf,benchmark-infra): authorize realtime audio benchmark + advisory CI (SysRS-307..309 / SysDes-156..158 / SRS-216..219 / SAD-088..091 / SDD-120)
Author the full SysRS -> SysDes -> SRS -> SAD -> SDD chain for the benchmark infrastructure authorized by the Option B product decision (Dimensions 1 + 2-advisory; Dimension 3 telemetry export deferred to P1; build-failing hard CI gate deferred until baseline maturity).

SysRS v0.9.10 adds:
- SysRS-307: maintained numeric performance baselines for the realtime audio path (allocations per callback after warmup, callback wall-clock, Opus encode/decode latency, resampler throughput).
- SysRS-308: advisory CI regression reporting on PR + merge to default; non-blocking semantics.
- SysRS-309: explicit declared tolerance window.

SysDes v0.9.8 adds:
- SysDes-156: benchmark coverage allocated to SE-13 (Audio Subsystem).
- SysDes-157: CI advisory-reporting integration allocated to SE-18 (Deployment).
- SysDes-158: per-metric tolerance table (zero / +20% p95 / +15% mean / -10% throughput).

SRS v0.9.9 adds:
- SRS-216: realtime audio benchmark instrumentation in chanora_audio/benches/.
- SRS-217: baseline storage format (JSON with metric/value/unit/host_arch/toolchain/git_sha/timestamp).
- SRS-218: CI advisory workflow with non-blocking semantics.
- SRS-219: tolerance window binding + merge-base comparison methodology.

SAD v0.9.9 adds:
- SAD-088: chanora_audio criterion bench harness (extends SAD-034).
- SAD-089: baseline JSON path pinned to crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json.
- SAD-090: advisory CI workflow file (.github/workflows/bench-advisory.yml).
- SAD-091: manual-trigger baseline-update workflow (sole writer of SAD-089).
- Yellow-marker semantics pinned at SAD: 50%-of-tolerance trending detection.

SDD v0.9.15 adds:
- SDD-120: criterion 0.5 + dhat 0.3 dev-deps; three bench files (realtime_capture, opus_codec, resampler) + common.rs; two post-processor binaries (emit_baseline, compare_baseline); two GitHub Actions workflow YAMLs; simpler-form yellow-marker realization (baseline-only comparator).

Out of scope (deferred):
- Dimension 3 production telemetry export (P1).
- Build-failing hard CI gate (post-baseline-maturity).
- Multi-host benchmarking (Linux x86_64 only).
- Dart-side flutter_rust_bridge round-trip benchmark.

Implementation follows in a separate commit per the no-huge-commit guideline.
2026-05-18 13:36:01 +08:00
EdisonJwa d13b56d379 perf(audio): pre-allocate capture scratch buffers to avoid realtime-thread Vec allocs (SDD-094)
The capture cpal callback (CaptureState::ingest) ran two heap
allocations per callback on the realtime audio thread:

  1. engine.rs:1196-1202 — fresh `mono: Vec<f32>` for the downmix
     output, once per cpal callback (50–100 Hz).
  2. engine.rs:1217-1218 — `pcm_accum.drain(..FRAME_SAMPLES).collect()`
     building a fresh Vec<f32> of 960 samples per Opus frame.

Both sites mirror the pattern already fixed for the output side at
engine.rs:1389-1397, where allocating per callback on glibc malloc
was correlated with user-perceptible audio popping. The output-side
fix replaced the per-callback allocation with a pre-allocated
`scratch` Vec that is cleared and resized in place; this commit
applies the same template to the capture side.

Changes:
- Add `mono_scratch: Vec<f32>` and `frame_scratch: Vec<f32>` to
  CaptureState. Initialised with Vec::with_capacity(4096) and
  Vec::with_capacity(FRAME_SAMPLES=960) respectively in
  CaptureState::new.
- Replace the downmix Vec construction with in-place push into
  `self.mono_scratch`; `clear()` retains capacity across callbacks.
- Replace the drain().collect() with `self.frame_scratch.extend(
  self.pcm_accum.drain(..FRAME_SAMPLES))`; same capacity-retention.
- The resampler call uses std::mem::take to swap the scratch buffer
  out for the duration of the &mut self call, then moves it back —
  the backing allocation is preserved across callbacks.

Algorithm semantics are unchanged: same downmix arithmetic, same
clamp loop, same Opus encode call sequence. Only the storage
strategy differs.

Out of scope (intentionally not touched):
- Android audio path (android_voice_unit.rs, mobile_voice_backend.rs):
  researcher constraint C-4 — the Android cpal data path is mid-
  migration and being replaced.
- Output callback at engine.rs:1409+: the only obvious per-callback
  allocation there (`scratch`) was already fixed; a fuller audit
  is a separate scope decision.
- The `scratch` buffer at engine.rs:1389-1397 — already correct.

Verification:
- cargo check --workspace --all-targets: passes.
- cargo test --workspace: 106 passed / 0 failed / 3 ignored.
- cargo clippy --workspace --all-targets: no new lints introduced;
  the one warning inside the edited region (clamp-like pattern at
  line 1266) was pre-existing on the copied clamp loop.
2026-05-18 13:01:52 +08:00
EdisonJwa 5aa51c310f docs(p0): SysRS/SysDes/SRS/SAD/SDD/Verification + traceability for Android P0 reconciliation
Full P0 Android documentation chain:

- SysRS: API 24 → API 28 reconciliation per DEC-004 (SysRS-288);
  add SysRS-305 (Android in-call audio mode), SysRS-306
  (RECORD_AUDIO runtime timing).
- SysDes: SysDes-152 (in-call audio mode subsystem), SysDes-153
  (RECORD_AUDIO permission flow), SysDes-154 (Android voice audio
  backend), SysDes-155 (macOS runtime baseline).
- SRS: SRS-187 → API 28; add SRS-208 (in-call audio mode), SRS-209
  (RECORD_AUDIO + listen-only fallback), SRS-210..215 (Android voice
  audio backend latency/preset/AEC/usage/sharing/foreground service);
  retarget SysDes anchors from generic SysDes-135 to SysDes-152/153/154.
- SAD: SAD-063 refreshed (API 28); add SAD-084 (audio mode
  controller), SAD-085 (permission adapter with listen-only),
  SAD-086 (foreground service), SAD-087 (macOS runtime baseline);
  formalize cross-cutting + platform-specific allocation pattern
  in §24.1.
- SDD: expand SDD-028 (BackIntentService); refresh SDD-073 (build
  config); add SDD-105 (JNI bootstrap), SDD-106 (permission
  requester), SDD-107 (foreground service), SDD-108 (audio mode
  controller), SDD-109 (AAB pipeline), SDD-110 (PTT capability),
  SDD-111..116 (Android voice audio backend), SDD-118 (Android
  bridge build automation), SDD-119 (iOS/macOS bridge build
  automation back-fill).
- Verification: create android-p0-acceptance.md TC-1..TC-18; add
  SWE4-UV-040..052, SWE5-IV-016..026, SWE6-SV-018..030,
  SYS4-SIV-015 strengthened + SYS4-SIV-017/018.
- Governance: traceability matrix v0.9.9 with end-to-end chain
  closure; DEC-032 documents the temporary abiFilters reduction to
  arm64-v8a only and its restore-by gate.

Trace: full chain SysRS → SysDes → SRS → SAD → SDD → Code → Verification.
2026-05-18 12:48:28 +08:00
EdisonJwa 4c19410556 test+diag: SWE.4 unit tests for Rust paths, Dart service tests, diagnostics audio.android section
Verification + diagnostics:

- apps/chanora_flutter/test/services/back_intent_policy_test.dart:
  8-case truth table for the BackIntentPolicy pure function
  (SWE4-UV-042).
- apps/chanora_flutter/test/services/back_intent_service_test.dart:
  5 channel-routing tests (SWE4-UV-042, SWE5-IV-019 unit slice).
- apps/chanora_flutter/test/services/android_permissions_service_test.dart:
  12 tests covering inbound channel events, outbound requests,
  state-machine transitions, and non-Android short-circuit
  (SWE4-UV-041).
- SDD/SRS trace headers added to alpha_e2e_test.dart,
  beta_e2e_test.dart, widget_test.dart so the existing test ↔ ID
  mapping is discoverable by grep.
- chanora_diagnostics: extends DiagnosticExport with android_audio:
  Option<String> for the SDD-116 evidence schema (requested /
  achieved performance mode + sharing mode + input preset + sample
  rate + frames per burst, per-effect engagement, latency tier).
  The bridge's export_diagnostics() now embeds the Android section
  when current_android_audio_diagnostics() returns Some.

All 93 workspace Rust tests and 26 Dart test/services tests pass.

Trace: SDD-090, SDD-112, SDD-113, SDD-116, SWE4-UV-041, SWE4-UV-042,
SWE4-UV-045, SWE4-UV-047, SWE4-UV-048, SWE4-UV-049, SWE4-UV-051,
SWE4-UV-052, SWE5-IV-019.
2026-05-18 12:48:28 +08:00
EdisonJwa c4145a8727 feat(flutter,android): permission state banner + AndroidPermissionsService + voice-join gate
Dart consumer for the Android permission state pipeline. New
AndroidPermissionsService listens on the app.chanora/android_permissions
MethodChannel and exposes a ValueListenable for the UI. The voice-join
flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and
clamps to listen-only via setHardMute on denial. A non-modal banner
above the VoiceBar surfaces the Grant / Open Settings action depending
on whether the state is Denied or PermanentlyDenied. On non-Android
hosts the service short-circuits to granted; the banner is never built.

Also adds the BackIntentService Dart consumer (back_intent_policy +
back_intent_service) which the Kotlin BackIntentBridge invokes via
MethodChannel for deterministic route-pop ordering.

Trace: SDD-028, SDD-106, SRS-163, SRS-209.
2026-05-18 12:48:28 +08:00
EdisonJwa 0dc8297568 feat(android,p0): foreground service, permission requester, application class, build automation
Android P0 platform shell:

- ChanoraApplication: early System.loadLibrary("c++_shared") +
  System.loadLibrary("chanora_bridge") so JNI is hot before
  MainActivity.onCreate.
- MainActivity: configureFlutterEngine + onResume/onDestroy wiring
  for BackIntentBridge and AndroidPermissionRequester; publishes
  permission-state changes through both the MethodChannel (Dart UI)
  and the JNI hook (Rust audio engine).
- AndroidVoiceForegroundService: microphone-type foreground service
  with notification channel chanora.voice.session per SDD-107.
- AndroidPermissionRequester: RECORD_AUDIO state machine with
  persisted "has-ever-requested" flag so PermanentlyDenied is
  correctly distinguished from never-asked across cold launches.
- BackIntentBridge: API 33+ OnBackInvokedCallback + pre-33
  OnBackPressedDispatcher with deterministic Dart-side policy.
- MethodChannels: centralized constants for app.chanora/*.
- build.gradle.kts: SDD-118 Gradle automation that auto-builds the
  Rust cdylib via cargo-ndk with per-ABI Exec tasks, minimal-env
  isolation, CMAKE_TOOLCHAIN_FILE pinning, libc++_shared.so staging,
  release-inspection assertion. abiFilters temporarily reduced to
  arm64-v8a only per DEC-032 (multi-ABI restoration pending).
- AndroidManifest.xml: INTERNET, RECORD_AUDIO, FOREGROUND_SERVICE,
  FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS,
  MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT permissions; service
  declaration with foregroundServiceType=microphone.
- proguard-rules.pro: keep rules for JNI native methods + Flutter
  plugin entry points + FRB bindings.

Trace: SDD-073, SDD-105, SDD-106, SDD-107, SDD-108, SDD-110, SDD-118,
SRS-111, SRS-119, SRS-163, SRS-187, SRS-209, SRS-215.
2026-05-18 12:35:19 +08:00
EdisonJwa 7966a7c8c6 feat(bridge,android): BridgeEvent::PermissionState + JNI publish hook + c++_shared link
Per SDD-106 §5 add BridgeEvent::PermissionState{permission, state}
with the PermissionStateKind enum (Granted, Denied, PermanentlyDenied,
Unknown). The Kotlin side publishes mid-session permission changes
through a new JNI entry point Java_app_chanora_chanora_1flutter
_MainActivity_publishPermissionState routed by the new
permission_jni.rs module; the Rust audio engine subscribes and
authoritatively clamps the transmit gate (see SDD-106 §6).

Adds crates/chanora_bridge/build.rs to emit
cargo:rustc-link-lib=dylib=c++_shared on Android so libchanora_bridge
.so carries DT_NEEDED libc++_shared.so; this is required by Android
API 24+ per-library linker namespaces to resolve __cxa_pure_virtual
and friends at System.loadLibrary time.

Includes the FRB-regenerated Dart counterparts so each commit is
independently buildable.

Trace: SDD-105, SDD-106 §5, SDD-118 item 6 (extended).
2026-05-18 12:32:01 +08:00
EdisonJwa 56222d190e feat(audio): clamp transmit selector on RECORD_AUDIO permission state (SDD-106 §6)
Per SDD-106 §6 add a permission-state clamp to TransmitModeSelector.
When RECORD_AUDIO is Denied or PermanentlyDenied the transmit gate
is forced false regardless of PTT or voice-activity state; on
Granted the clamp releases and normal transmit decisions resume.
The clamp takes precedence over PTT and hard_mute in the decision
ordering documented inline.

Three new tests cover the clamp behavior, the release-on-grant
transition, and the non-RECORD_AUDIO ignore path.

Trace: SDD-106 §6, SRS-209.
2026-05-18 10:56:17 +08:00
EdisonJwa 78190c0694 feat(audio,android): wire engine + AudioManager JNI through ModeStack (SDD-108 §2)
Replace the prior one-shot android_engage_voice_communication call
with a ModeStack-mediated acquire/release pair. AudioEngine snapshots
the system audio mode on first acquire via android_get_audio_mode()
and restores it on last release via android_set_audio_mode(prior).
MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime
per SDD-108.

Includes the Android AudioManager getMode/setMode JNI helpers
(placed in chanora_audio::engine alongside the existing JNI surface)
and the small ptt.rs touch needed for the SDD-108 ID-tag on the
existing tests.

Trace: SDD-108, SDD-115.
2026-05-18 10:53:18 +08:00
EdisonJwa 76c6d1d40c feat(audio,android): add MobileVoiceAudioBackend + AndroidVoiceUnit (oboe-rs)
Add the cross-platform MobileVoiceAudioBackend trait, plus the
Android implementation AndroidVoiceUnit backed by oboe-rs 0.6.x.
AndroidVoiceUnit owns AAudio stream setup with VoiceCommunication
usage/preset, performance-mode LowLatency request, sharing-mode
Exclusive best-effort, hardware AEC/NS/AGC engagement via JNI, and
the diagnostics snapshot publish path used by SDD-116 evidence
collection.

Cargo.toml: adds oboe = "0.6" under the Android target.

Trace: SDD-111, SDD-112, SDD-113, SRS-210, SRS-211, SRS-212, SRS-213,
SRS-214.
2026-05-18 10:38:19 +08:00
EdisonJwa da0b208075 feat(audio): add ModeStack pure refcount helper (SDD-108)
Introduce ModeStack, a pure-Rust refcount-composable wrapper for
Android audio-mode acquire/release with prior-mode snapshot. Per
SDD-108 §1/§2 the engine snapshots the system audio mode on first
acquire and restores it on last release; composed acquires are
no-ops while the mode is held.

ModeStack is panic-free; release-on-zero returns AlreadyReleased
rather than panicking. Six SWE4-UV-045-tagged unit tests cover the
acquire/release semantics on the host target.

Trace: SDD-108, SWE4-UV-045.
2026-05-18 10:30:25 +08:00
Edison Jwa dc9c5c0a4e feat: multi-platform bug fixes, Android audio path, and build tooling
Flutter UI fixes:
- Fix stale channel badge/speaker when moved by others (derive current
  channel from ownClientId instead of optimistic local state)
- Fix Linux PTT via focused fallback key handler
- Distinguish ServerQuery clients with terminal icon in client list
- Reduce duplicate current-channel badge display
- Prevent PTT key-bind save from permanently closing voice settings
- Fix Linux GTK reopen-after-close (quit app on window destroy)
- Fix focused PTT: consume key events, release held keys on
  disconnect/leave-channel/mode/backend changes, suppress stale errors

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

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

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

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

Rust bridge:
- Guard open_log_file() on non-Android (Android uses logcat)
2026-05-18 00:13:21 +09:00
Edison Jwa 00692b76a2 fix(macOS): enable Hardened Runtime + outgoing network for all configs
Debug and Profile had ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO which
blocked outbound TCP. All three configs now have:
- ENABLE_HARDENED_RUNTIME = YES
- ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES

This was the root cause of the 'Operation not permitted' connection
failure on macOS Sequoia.
2026-05-17 22:32:30 +09:00
Edison Jwa 72c6e14797 feat: modern macOS window chrome + Linux build script
macOS:
- Transparent title bar with hidden title, full-size content view
- macOS: inline Row header (no AppBar) with 56px traffic-light pad
- Other platforms: standard Material AppBar unchanged
- App name 'Chanora' in CFBundleName/CFBundleDisplayName (iOS + macOS)
- NSLocalNetworkUsageDescription added to both platforms

Linux:
- tools/build-linux.sh: builds Rust .so + Flutter bundle + tarball
- Verifies GTK3, libopus dev headers, Rust target
- Copies libchanora_bridge.so into bundle/lib/
2026-05-17 22:29:42 +09:00
Edison Jwa 63b102ed27 feat: add NSLocalNetworkUsageDescription to iOS and macOS Info.plist
Required for local network privacy prompt on macOS 15+ and iOS 14+.
App appears in System Settings → Local Network after connecting to a
LAN server. Internet-hosted servers only need network.client entitlement.
2026-05-17 22:10:39 +09:00
Edison Jwa 4ecea09bbc feat: add permission-denied dialog for macOS network/mic access
When macOS denies network access (PermissionDenied), show a localized
dialog explaining how to grant permission in System Settings, with
an 'Open System Settings' button that opens directly to the Local
Network privacy pane.

Also adds author info (Edison Jwa) to About dialog and moves
diagnostics button from AppBar into About dialog.
2026-05-17 21:57:56 +09:00
Edison Jwa b97cc9589b chore: upgrade dependencies to fix low-severity vulnerability
- connectivity_plus 6.1.5 → 7.1.1
- package_info_plus 8.3.1 → 10.1.0
- package_info_plus_platform_interface 3.2.1 → 4.1.0
- win32 5.15.0 → 6.2.0
- json_annotation 4.11.0 → 4.12.0
- ffi_leak_tracker added (0.1.2)
2026-05-17 21:48:24 +09:00
Edison Jwa 618b6930fe feat: add macOS bridge podspec, Windows project, sync versions to 0.2.0-beta.1 2026-05-17 21:42:54 +09:00
Edison Jwa 7a59f5b9a1 feat(ios,p0): iOS P0 platform, audio fixes, channel UX 2026-05-17 22:00:00 +09:00
EdisonJwa a1fefc8ab6 fix(audio,ios): revert ring buffer back to direct fill_buffer call (rc.8+75)
The ring-buffer architecture (rc.8+73..+74) was making playback
strictly worse. Diagnostic data at +74 conclusively showed:

  * Producer task ran perfectly at 50 Hz (250 ticks per 5 s).
  * AudioHandler returned silence on 65-84% of fill_buffer calls
    even when window_peak_f32 reached 0.98 (full-scale audio).
  * Ring buffer never accumulated beyond 30 ms because consumer
    (VPIO render callback at 43.5 Hz, ~1440 samples per call)
    drained samples faster than the 50 Hz producer could push
    them, in net effect.

The producer drained AudioHandler at 50 Hz \u2014 slightly faster
than iOS VPIO actually consumes audio. Each fill_buffer call
asked for 20 ms but adjacent Opus packets hadn't arrived yet, so
fill_buffer returned mostly silence. Linux/SDL's same pattern
works because SDL calls fill_buffer at EXACTLY the device
callback rate (50 Hz = 20 ms per buffer); the rates match.

Fix: revert to direct fill_buffer call from the render callback
(the SDL pattern in tsclientlib's own reference example at
tsclientlib/examples/audio_utils/ts_to_audio.rs). The render
callback now:

  1. Resizes scratch_stereo Vec to 2 * num_frames f32 if needed
  2. Zeros the live slice (fill_buffer is additive, not clearing)
  3. Locks AudioHandler, calls fill_buffer(scratch_stereo)
  4. Downmixes L+R -> mono i16 with master gain into out[]
  5. Applies output_muted bypass
  6. Tracks peak_out + audio/silence ratios for diagnostic

The closure owns scratch_stereo across callbacks for stable
allocation. Same memory model as Linux/SDL.

Removed:
  * tokio::spawn producer task
  * rtrb dep + RingBuffer<i16> + Producer/Consumer split
  * tokio::sync::oneshot shutdown channel
  * producer_shutdown_tx field on IosVoiceUnit struct
  * RING_BUFFER_SAMPLES / PRODUCER_TICK_MS constants
  * Producer-side diagnostic counters

Diagnostic kept: cb / num_frames / frames_changes /
callbacks_with_audio / callbacks_with_silence / peak_out_i16 /
gain. Logged every 100 callbacks.

The choppy / clicks symptom is independent of the buffer
architecture \u2014 it's whatever AudioHandler is doing on iOS
that's different from Linux. Next investigation step is to
either (a) switch from VPIO to RemoteIO unit (lose Apple's
voice processing entirely), or (b) understand why AudioHandler
returns silence so often on iOS-arrival packet timing patterns.

Build counter 74 -> 75.
2026-05-17 13:05:09 +08:00
EdisonJwa a9aa19ecdd diag(audio,ios): comprehensive producer + consumer ring-buffer metrics (rc.8+74)
External reviewer correctly identified that the +73 ring-buffer
commit didn't fix the symptom but the architecture is still
right. We need to distinguish two possible causes:

  (a) Producer task isn't running (or running too rarely) so
      ring stays underfilled.
  (b) Producer IS running but fill_buffer returns zeros most of
      the time (AudioHandler stuck in buffering_samples state
      or no packets reaching it).

The +73 render-side diagnostic was insufficient: we logged
underruns + peak_out_i16 but not what the producer was
actually pushing. This commit adds producer-side metrics
rolled up every 5 s (250 ticks at 20 ms):

Producer task:
  producer_ticks           : timer firings (= ~250 per 5 s window;
                             fewer = tokio scheduler stalled)
  produced_chunks          : pushes into ring (= ticks - drops)
  fill_buffer_calls        : AudioHandler queries
  fill_buffer_zero_returns : ticks where scratch came back all
                             zeros (no decoded content to play)
  ring_full_drops          : ticks where ring was full and we
                             skipped the push
  ring_min/max_samples     : depth envelope across window
  ring_min/max_ms          : same in milliseconds
  window_peak_f32          : max scratch sample across window
  window_rms_f32           : RMS of all scratch samples across
                             window

Render callback (per-100-callback as before, plus new fields):
  ring_avail_before        : Consumer::slots() before this read
                             (= how many samples were sitting in
                              the ring at callback entry)
  read_frames              : samples successfully popped
  zero_filled              : samples zero-filled because ring
                             was empty (= num_frames - read_frames)
  underruns / underrun_samples / peak_out_i16 / clip_count_i16
                           : as before

Reading the next iteration's log:

  If producer_ticks << 250  per 5 s window:
      tokio scheduler isn't running the task fast enough.
      Move producer to its own dedicated runtime, or use
      std::thread + std::sync::mpsc + std::thread::sleep
      instead of tokio.

  If producer_ticks ~= 250 AND fill_buffer_zero_returns is
  high (most ticks return silence):
      AudioHandler isn't decoding packets fast enough OR is
      stuck buffering. Bug is upstream in protocol layer
      packet delivery or AudioHandler's jitter state machine.
      The ring buffer architecture cannot fix this.

  If producer_ticks ~= 250 AND fill_buffer_zero_returns is
  low AND ring_min_ms stays >100ms AND underruns are low BUT
  consumer's peak_out_i16 is still 0:
      Something is wrong between push and pop. Lock-free
      ring corruption, or wrong stride.

Pure diagnostic. No behavioural change beyond the logging.
Producer scratch envelope scan is O(scratch.len()) = 1920
samples per 20 ms tick = ~96k iterations/sec on the audio
producer thread \u2014 negligible CPU.

Build counter 73 -> 74.
2026-05-17 02:47:35 +08:00
EdisonJwa 99584fbc1a fix(audio,ios): decouple AudioHandler from VPIO render callback via ring buffer (rc.8+73)
User confirmed at +72 the symptom is 'voice + constant clicks +
choppy fragments'. The diagnostic data conclusively pointed to
iOS VPIO render-callback timing as the cause:

  * frames_changes=60+ per 100 callbacks at cb>=1800
    iOS keeps switching num_frames between 960 and 1104
    on roughly 60% of callbacks
  * peak_out_i16 is sensible (2500-16870, never clipping)
    when AudioHandler returns content
  * input_was_zero=true on most callbacks during active speech
    AudioHandler keeps entering buffering_samples state

The cause: previous render callback called fill_buffer
synchronously every iOS audio thread invocation. With iOS
calling at irregular rates with irregular sizes, AudioHandler's
jitter buffer (sized around 20 ms Opus frames) cannot satisfy
arbitrary-sized requests and falls back to returning silence
(&[] empty slice) on misaligned reads. The silent gaps in the
middle of the output buffer create discontinuities = audible
clicks; the missing-tail content produces choppy fragments.

Fix (architectural): decouple the AudioHandler decoder from the
VPIO render callback via a lock-free SPSC ring buffer.

  Producer (tokio task, 50 Hz):
    every 20 ms:
      fill_buffer(scratch_stereo_f32, 1920 = 20 ms stereo)
      downmix L+R -> mono i16 (960 samples)
      ring_buffer.push_slice(mono_i16)

  Consumer (VPIO render callback, iOS audio thread):
    every callback:
      pop num_frames samples from ring buffer into out
      zero-fill tail on underrun

Why it works:
  * Producer always asks AudioHandler for a stable 20 ms chunk
    (perfectly aligned with internal Opus frame size). No more
    buffering_samples false-triggers.
  * Consumer pulls whatever iOS asks for whenever iOS schedules
    it; ring buffer's 200 ms depth absorbs the callback jitter.
  * This is the standard pattern every production VoIP audio
    engine uses (WebRTC, Discord, FaceTime) to bridge bursty
    Opus decoders to bursty platform audio callbacks.

Implementation:
  * New dep: rtrb 0.3.4 (RustAudio realtime-safe SPSC ring
    buffer, 6.8M downloads, lock-free push/pop with no
    allocation on the audio thread).
  * RING_BUFFER_SAMPLES = 9600 (200 ms mono i16 at 48 kHz).
    Sized for 10x producer ticks of headroom.
  * PRODUCER_TICK_MS = 20 (matches Opus 50 Hz packet rate).
    set_missed_tick_behavior(Skip) to avoid burst catch-up on
    runtime stalls.
  * Producer task spawned in IosVoiceUnit::start, shutdown
    via tokio::oneshot when IosVoiceUnit drops.
  * Render callback is now just: pop into out, zero-fill tail,
    apply mute then gain.
  * Gain applied CONSUMER-side so user volume changes take
    effect within one callback (<= 200 ms latency).
  * Underrun diagnostics: count underrun callbacks + total
    zero-filled samples, log every 100 callbacks.

Threading + safety:
  * rtrb is lock-free SPSC. Audio thread never blocks.
  * Producer can block briefly on Arc<Mutex<AudioHandler>>
    contention with the inbound forwarder (handle_packet), but
    not with the audio thread.
  * Producer task is owned by tokio runtime; explicit shutdown
    channel ensures it exits when the engine stops.

Build verify:
  * Linux host: cargo check clean in 4.06s (downloads rtrb 0.3.4).
  * iOS Mac:   cargo check clean in 2.14s.

Build counter 72 -> 73.
2026-05-17 02:39:42 +08:00
EdisonJwa 53e09ea091 diag(audio,ios): comprehensive render-callback metrics per external review (rc.8+72)
External code review pushed back on the 'iPhone speaker hardware
distortion' hypothesis and pointed out we need more than just
peak measurements. The reviewer's checklist:

  * peak_i16
  * rms_i16
  * num_clipped_samples (abs >= 32767)
  * zero_fill_count / underrun_count
  * callback_frame_count variability
  * decoded_packet_duration_ms
  * input_was_zero
  * actual ASBD / actual sample rate

The format diagnostics at +71 already showed iOS honoured
48 kHz Int16 mono on both buses and that .default mode +
.defaultToSpeaker routed to the speaker correctly with
outputVolume=0.45. So format + route are confirmed correct.
The remaining mystery is WHY 'loud but distorted' \u2014 we need
sample-level metrics to isolate where in the pipeline the
breakage occurs.

This commit instruments the VPIO render callback with:

  * num_frames + frames_changes : detects iOS re-negotiating
                                 buffer size between callbacks
                                 (which would imply jitter the
                                 fixed scratch_stereo Vec can't
                                 absorb cleanly).
  * peak_stereo + rms_stereo  : characterises AudioHandler's
                                output BEFORE our downmix.
                                Distinguishes 'real audio
                                arriving' from 'silence'.
  * peak_out_i16 + clip_count : measures what we hand VPIO.
                                clip_count > 0 means we're
                                clipping at our boundary even
                                with gain=1.0 \u2014 indicates
                                upstream is over-driven.
  * input_was_zero            : explicit silence/no-talker
                                indicator separate from peak=0
                                which could mean tiny content
                                rounded to 0.

Reviewer's preferred diagnostic path is to dump PCM to file
and play with ffplay externally; that's iOS-impractical
without a shared filesystem path the user can extract via
Files.app. Instead we sample the same metrics in-callback at
~2 Hz which gives us the same information at run time.

Pure diagnostic. No behavioural change. Counters live in the
FnMut closure so the audio thread cost is one branch +
counter increment per callback, plus a one-pass RMS sum +
peak scan every 100 callbacks.

Build counter 71 -> 72.

Reviewer also recommended a headphone test in parallel \u2014
that will be done by the user (out-of-band) at the next
test cycle to determine whether the symptom changes when
audio leaves the speaker path.
2026-05-17 02:25:31 +08:00
EdisonJwa 2735c55c97 diag(audio,ios): log actual VPIO + AVAudioSession state post-init (rc.8+71)
Per external review (helpful checklist from ChatGPT-style analysis
pointing out we never verified that iOS actually accepted our
preferred sample rate / channels / format): preferredSampleRate
and preferredIOBufferDuration are HINTS, not guarantees. iOS may
substitute its own values if the hardware can't satisfy our
preference. If VPIO is running at 44.1 kHz Float32 stereo while
our render callback writes 48 kHz Int16 mono into the buffer,
the symptoms would match what user reports (broken playback,
pitch shifted, severe distortion) and our previous diagnostics
wouldn't catch it because they only sampled signal-level metrics.

This commit adds two diagnostic emissions to verify:

1. AppDelegate.swift::activateAudioSession: after setActive
   succeeds, log the ACTUAL session state \u2014 category, mode,
   sampleRate, ioBufferDuration, current route (inputs +
   outputs), outputVolume. Lets us see whether iOS honoured our
   .default + .defaultToSpeaker setup and which physical route
   it picked at launch.

2. ios_voice_unit.rs::IosVoiceUnit::start: after unit.start()
   succeeds, log the actual OUTPUT and INPUT stream formats
   VPIO accepted (sample_rate, channels, sample_format, flags).
   If these differ from our requested 48 kHz Int16 mono, we
   have a format-substitution problem.

Three possible outcomes from the next test:

* Both diagnostics confirm 48 kHz Int16 mono on both buses and
  the session sampleRate=48000 -> format is correct; the
  playback breakage is somewhere else (e.g. AudioHandler
  jitter buffer behaviour, route binding, or hardware mixer).

* Session sampleRate != 48000 -> we need to insert a sample
  rate converter or pin AVAudioSession's
  setPreferredSampleRate(48000) explicitly in Swift before
  setActive.

* VPIO substituted Float32 for our Int16 request -> our render
  callback is writing i16 magnitudes into a Float32 buffer
  which would explain the distortion. Fix: write Float32
  directly using data::Interleaved<f32> instead of i16.

Build counter 70 -> 71. Pure diagnostic; no behavioural
change.
2026-05-17 02:17:37 +08:00
EdisonJwa 6e0bf21295 fix(audio,ios): route playback via media channel (.default + .defaultToSpeaker) (rc.8+70)
User report after the 8x boost commit (e85a6d3): playback STILL
broken, but now the diagnostic clearly shows the actual problem.
Render-callback peak_out_i16 SATURATES at 32767 on speech peaks
(cb=600, 1100, 1200, 2400) because the 8x boost amplifies an
already-loud signal into hard clipping. Quiet content reaches
audible level but loud peaks are catastrophically distorted.

The 8x boost was treating the wrong cause.

Real root cause (researched online after user prompted: 'this is
iOS a popular platform, there must be solutions'): iOS has TWO
independent audio channels:

  In-call channel  (.voiceChat / .videoChat modes)
    * Routes through the phone-call audio path.
    * Aggressively ducks non-voice content to the earpiece.
    * Volume controlled by a separate in-call hardware
      register, not the side buttons when not actively on a
      phone call.

  Media channel  (.default mode)
    * Routes through the standard media playback path.
    * No automatic ducking.
    * Volume controlled by the side volume buttons normally.

With AVAudioSession mode .voiceChat, iOS sends our output
through the in-call channel which plays at 'earpiece-level'
loudness on the speaker too. Signal is technically present but
buried under the speaker's noise floor. With mode .default +
.defaultToSpeaker option, output routes via media channel and
plays at normal loudness.

Both Twilio (video-quickstart-ios) and Daily.co (patched WebRTC
module) document the same workaround and use VPIO for AEC while
keeping the session mode at .default for loud playback:

  github.com/twilio/video-quickstart-ios/issues/522
  stackoverflow.com/questions/79834998 (Daily.co)

The user also noticed 'tx/rx almost no changes even receiving
packages' \u2014 likely a misinterpretation of the frames counter
not advancing as fast as expected during quiet voice; AudioHandler
returns silence when its jitter buffer is in buffering_samples
state which doesn't fire 'decode failed' but also doesn't
increment frames_received. The real issue is still the playback
ducking; the counter behaviour is a downstream symptom.

Changes:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default
   with options [.defaultToSpeaker, .allowBluetoothHFP,
   .allowBluetoothA2DP]. VPIO continues to do its job (AEC, NS,
   AGC on the mic side); only the playback routing changes.
   The earlier 'speaker selector silent under .default' bug
   does NOT apply because we no longer use cpal RemoteIO \u2014
   VPIO honours overrideOutputAudioPort under any mode.

2. ios_voice_unit.rs: revert the 8x output boost from e85a6d3.
   With media-channel routing, signal levels are correct and
   no software amplification is needed. Render callback restored
   to plain (l+r)*0.5*gain downmix.

3. ios_voice_unit.rs: revert the BypassVoiceProcessing toggle
   from c16318c. The VPIO chain stays enabled so we keep
   capture-side AEC/AGC/NS for free \u2014 the playback breakage
   it was trying to fix was the wrong layer all along.

4. ios_voice_unit.rs: drop the diagnostic render-callback log
   line. Production-clean code; can be re-enabled by reverting
   the diff in the closure if future debugging needs it.

Build counter 69 -> 70.
2026-05-17 02:11:07 +08:00
EdisonJwa e85a6d36d7 fix(audio,ios): apply 8x output boost to compensate for VPIO raw playback (rc.8+69)
User-pasted log at +66 (https://pb.hit.moe/q8heratf.txt) shows
conclusive data over a 110-second continuous talker session:

  Average peak_stereo_f32: ~0.005-0.010
  Loud peak (one moment):  ~0.234
  peak_out_i16:           ~150-300 (out of 32767)

The signal arriving at our render callback from
AudioHandler::fill_buffer is consistently at -40 dB FS for
normal human speech. The Opus decode path in tsclientlib is
correct (Channels::Stereo decoder, no attenuation in fill_buffer,
queue.volume defaults to 1.0). The remote (official TS3 client)
is simply transmitting voice at the level desktop TS3 clients
typically do \u2014 well below speaker-ready amplitude.

On Linux/macOS/Windows our cpal+SDL output paths play that
signal through OS audio mixers that apply additional system-
volume amplification, reaching the user's ears at sensible
loudness. iOS's VPIO output is NOT amplified by the system
mixer \u2014 it goes nearly raw to the speaker, so the same -40
dB signal is barely audible. Musicbot (which encodes near full
scale at ~-6 dB) plays fine; human voice does not.

Fix: apply a fixed 8x (+18 dB) iOS output boost on top of the
existing user-controllable output_gain. A -40 dB signal becomes
-22 dB (normal speakerphone level). User's volume slider
continues to function in a useful 0-2x range on top.

  effective_gain = user_gain * IOS_OUTPUT_BOOST

Hard-clip at \u00b11.0 in the mono downmix prevents loud signals
(musicbot at peak 0.5 -> 4.0 -> clamped to 1.0) from
overflowing i16 wrap-around. Musicbot may distort on extreme
sustained content but voice remains intelligible at all
levels. Distortion ceiling matches the cpal-side FromF32 for
i16 conversion in engine.rs.

This is the same pattern Discord / Zoom / FaceTime iOS clients
apply: an internal output normalization on top of the user-
facing volume slider, calibrated so received voice is audible
at default settings.

Build counter 68 -> 69.
2026-05-17 01:58:15 +08:00
EdisonJwa c16318c86b fix(audio,ios): bypass VPIO voice processing for clean playback (rc.8+68)
User report at +67 (.voiceChat mode): playback still 'broken'.
Even with VPIO's Apple-documented session-mode pairing,
its output-side gating chain (echo subtraction + adaptive
noise suppression) chops quiet inter-phoneme content of human
voice. Musicbot signal (loud, ~continuous) survives because it
stays above the gating threshold; speech does not.

Fix: set kAUVoiceIOProperty_BypassVoiceProcessing = 1 on the
unit immediately after EnableIO (before stream format / callbacks
/ initialize). This disables ALL VPIO voice processing \u2014 the
unit becomes effectively a vanilla RemoteIO with mic + speaker
buses. Raw samples pass through both directions.

Trade-off:
* Lost: Apple's hardware AEC + AGC + NS on the mic path. User
  reports current capture is clean already, suggesting their
  test environment (headset? non-speakerphone?) doesn't need
  AEC. If echo loops back when speakerphone is engaged, we'll
  re-evaluate \u2014 either re-enable VPIO selectively for
  echo-prone routes or ship software AEC (DEC-007).
* Gained: playback is no longer gated. Quiet inter-phoneme
  speech content reaches the speaker.

Property setter:
* Constant: kAUVoiceIOProperty_BypassVoiceProcessing = 2100
* Scope: Global, Element: Input (1) per WebRTC's reference iOS
  ADM (voice_processing_audio_unit.mm).
* Value: u32 = 1 (= bypass).
* Soft-fail with warn log if the property is rejected on an
  exotic iOS version (the unit still works, just with VPIO
  defaults).

Build counter 67 -> 68.
2026-05-17 01:54:07 +08:00
EdisonJwa c89acacc74 fix(ios,audio): pair VPIO with AVAudioSession mode .voiceChat (rc.8+67)
User report at +66: capture (mic -> remote) is clean, but local
playback (remote -> speaker) is 'broken and poor', particularly
for human voice. Musicbot audio (loud, near-continuous) plays
correctly; human voice (peaks ~-6 dB, average ~-40 dB, classic
20 dB peak-to-average ratio) sounds gated out so most inter-
phoneme content is unintelligible.

Diagnostic at +66 (render callback peak sampling every 100
callbacks during a 60-second talker session) showed peak_stereo
values in the 0.005-0.01 range with occasional 0.13-0.49 spikes
\u2014 i.e. the signal is REAL and reaching the device, but VPIO's
output-side voice processing chain is gating the average-level
content.

Root cause: AVAudioSession mode .default + VPIO is a mismatched
pairing. Under .default mode the VPIO unit's internal AGC/NS
thresholds are tuned wrong for telephony-style speech and treat
quiet inter-phoneme content as noise to gate out.

Fix: switch back to mode .voiceChat which is Apple's documented
pair for VoiceProcessingIO. WebRTC's reference iOS audio device
manager (chromium googlesource voice_processing_audio_unit.mm)
also uses this pair. VPIO under .voiceChat tunes its processing
chain for speech and passes quiet content through cleanly.

The original 'speaker/receiver toggle is silent under .voiceChat'
bug was caused by cpal's RemoteIO unit binding to a stale
physical transducer at construction time, not by .voiceChat
itself. After migrating to VPIO at commits 1-4 (af686ca through
e7c3ffa) the route binding is correct under either mode because
VPIO natively re-binds on overrideOutputAudioPort \u2014 it IS the
canonical voice unit. So .default lost its only benefit and we
revert to the Apple-documented pairing.

Category options unchanged: .allowBluetoothHFP +
.allowBluetoothA2DP \u2014 BT headsets still permitted in both
directions regardless of mode.

Build counter 66 -> 67.
2026-05-17 01:44:05 +08:00
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
EdisonJwa 61798e5bdf docs(verification,index): update test counts + add 0.9.6 row for rc.8 acceptance
- windows-p0-acceptance.md: bump auto-test row to reflect current
  Windows test count (126/0/1 — was 124/0/2 before the two
  new start_flips_armed_to_l2 tests landed).
- document-index.md: new 0.9.6 row marking DEC-031 (missed-key-up
  watchdog disabled on P0) and the controlled status of
  docs/verification/windows-p0-acceptance.md.
2026-05-16 10:13:22 +08:00
EdisonJwa 01003b3448 refactor(protocol): clarify pending_moves expiry sweep
The original mem::replace + retain pattern worked but was opaque.
Switch to a two-pass approach: collect expired MessageHandles into
a small Vec, then remove + resolve. Behaviour-preserving; the
clippy-style readability win is worth the tiny extra allocation
(typical case: 0 or 1 expired entries per loop iteration).
2026-05-16 10:10:03 +08:00
EdisonJwa 2511b24982 fix(audio,protocol,ui): TC-2.3 + TC-10 + TC-13 + channel tree hierarchy
Five user-reported defects + one auto-test regression-catcher.

== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.

Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.

Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.

New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.

== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.

Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.

Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
  carries the canonical TS3 error code per the official catalogue
  at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
  The connection-task loop holds a pending_moves HashMap keyed by
  MessageHandle, services StreamItem::MessageResult by looking up
  and resolving the reply with either Ok or the typed
  ServerRejected.
* Pending entries have a 3 s deadline so a server that never
  replies doesn't leak the reply channel — expired entries fall
  back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
  full snapshot poll), still polls for confirmation as a
  belt-and-braces fallback for legacy servers; on poll failure
  emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
  CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
  localised message selected by error code (channelJoinFailed*
  l10n entries). 6 known codes mapped to specific messages
  (insufficient permission, wrong password, channel full,
  family limit, private channel, timeout); everything else
  falls back to the server-supplied generic message.

== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.

Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.

== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.

Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).

== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.

Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
  +2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
2026-05-16 03:27:25 +08:00
EdisonJwa 0b6ea11077 docs(verification): windows-p0-acceptance.md — human-side rc.8 sign-off
Captures the 15 TC rows the lead walks through on the Korean
Win 11 host before tagging v1.0.0-rc.8, plus the auto-test
sign-off matrix (Linux + Windows cargo + flutter analyze +
validator + windows-smoke).

Each TC row maps to a spec requirement (DEC / SRS / SDD) or to a
regression the rc.7 review found. Failures block the tag.

Known gaps recorded at the bottom: macOS / iOS / Android P0 are
separate documents; VAD (DEC-030) and missed-key-up watchdog
redesign (DEC-031) are P1; real RMS level meter is P1.
2026-05-16 02:11:21 +08:00
EdisonJwa 881321595e test(ptt): use capital 'Space' in set_binding_updates_descriptor_watch
The Windows Raw Input backend's keymap is case-sensitive; the test
passed lowercase 'space' which works for the Focused fallback on
Linux but fails on Windows where resolve_binding returns
InvalidBinding. Use the same canonical 'Space' string the bind
dialog produces in real use.
2026-05-16 02:06:02 +08:00
EdisonJwa 606d594dc9 fix(audio,windows): init_tx is SyncSender not Sender (windows-only build fix)
run_raw_input_loop / run_hook_loop signatures used Sender<bool>
but the callers create the channel via sync_channel which returns
SyncSender. Linux cross-check missed this because windows.rs is
behind #[cfg(target_os = "windows")].
2026-05-16 01:55:31 +08:00
EdisonJwa 181b3368d4 fix(audio,voice,log): six P0 issues from Korean Windows test
1. Voice Bar 'Leave voice' button removed entirely. TeamSpeak users
   are always in some channel; Discord/Mumble-style leave is the
   wrong model. To stop being heard / hearing, mute mic / speaker.
   To physically move, tap a different channel. The voiceLeave
   bridge call + _onLeaveVoice stay as dead code for now (marked
   unused) so existing tests/integrations don't break.

2. voice_join now confirms the move actually applied server-side
   by polling the snapshot for up to 1.5 s and matching our own
   client's channel against the requested one. If the server
   rejected the move (no permission, wrong password, channel
   full), voice_join rolls the selector back to in_channel=false
   and returns Err so the UI surfaces the failure instead of
   showing a fake 'joined' state.

3. ServerSnapshot + BridgeSnapshot gain own_client_id so the UI
   can identify our row without name-matching. find_own_in reads
   it directly.

4. set_self_muted now also clamps the TransmitModeSelector's
   hard_mute when input is muted server-side. Without this, the
   Opus encoder kept producing frames after setInputMuted(true),
   tsclientlib refused each one with 'Sending audio while muted',
   and the log grew to 200 MB on the Korean host.

5. tsclientlib WARN spam suppressed via tracing filter
   (tsclientlib=error). Belt-and-braces on top of fix 4.

6. Log file is now rotated at every launch (not just when >4 MiB).
   Two generations kept: chanora.log.1 (previous) and
   chanora.log.2 (the one before). The bug that produced 200 MB
   files was a chatty subsystem flooding a single session; the
   per-launch rotate keeps disk use bounded by what one session
   can produce in its lifetime.

Bonus Windows fix (separate from the six but found in the same
log): the Raw Input + Hook backends now signal readiness BEFORE
blocking on GetMessageW. Previously init_tx.send was called after
the loop returned (i.e. on WM_QUIT, which never happens during
arming), so the main thread's 2 s readiness probe always timed
out and the backend reported L0Focused even when registration
succeeded. Both run_raw_input_loop and run_hook_loop now take an
init_tx parameter and call report!(true) right after a successful
registration, and report!(false) on every early-fail return.

cargo check --workspace: clean.
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 01:52:47 +08:00
EdisonJwa bb3c82e2d5 chore(version): bump to v1.0.0-rc.8 (post-rc.7 v1 audio + PTT lifecycle work)
pubspec.yaml + _kAppVersion in main.dart were both still reading
v1.0.0-rc.1 even though the branch has accumulated 50+ commits of
post-rc.7 work (the v1 audio + PTT lifecycle redesign in
SDD-094..097, plus DEC-029/030/031). The About dialog and the
diagnostic export's app_version field both surfaced the wrong
string.

Aligned both to v1.0.0-rc.8+59 (build number is the current commit
count on the branch). The Rust workspace version stays at
0.0.1-pre — it's an internal pre-release marker the diagnostic
export carries as crate_version, not user-facing, and changing it
cascades into every inheriting Cargo.toml for no benefit.

The actual v1.0.0-rc.8 tag will land once the Korean Windows 11
human-side P0 acceptance pass closes; this commit aligns the
strings the running build shows so the tester sees a consistent
version while running the tests.
2026-05-16 01:07:00 +08:00
EdisonJwa 45fec2310e fix(ptt): disable missed-key-up watchdog on P0 (DEC-031, supersedes DEC-028)
The watchdog spawned by ChanoraSession::start_audio cleared
ptt_held after 30 s of continuous PTT key-down. That was correct
for the 'OS lost the key-up event' failure mode the original
SAD-079 / DEC-028 was designed to catch, but it was the wrong
shape for real human speech: anyone holding the bound key for a
long answer got cut off mid-sentence.

For P0:
- Comment out the spawn site in ChanoraSession::start_audio with
  the rationale + the P1 redesign options under consideration
  (raised ceiling / OS key-state polling / RMS-silence fallback).
- Leave the MissedKeyUpWatchdog Rust type, its spawn / spawn_on_signal
  entry points, and all unit tests in chanora_audio::ptt unchanged
  so P1 can re-enable with the chosen detection strategy without
  re-implementing anything.

Spec: new DEC-031 in product-decision-register.md supersedes
DEC-028 for the v1 ship. DEC-028 stays in the register as
historical context. The §7 open-decisions log + §8 change history
get matching 0.9.12 rows.

Note: Mumble and TeamSpeak ship without a comparable watchdog —
the 30 s ceiling was stricter than industry baseline. The
underlying protection (OS-level key-up loss) is still worth
solving, just not with a fixed timeout.

cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(unchanged; the watchdog unit tests still run because the type
itself is unchanged).
docs validator: clean (pre-existing 35-filename warning only).
2026-05-16 01:04:40 +08:00
EdisonJwa 6d4975bd6e fix(ptt,ui): Continuous mode no longer self-disables after 30 s; rename PTT label to Mic
Issue 1: in Continuous transmit mode the talk indicator turned
gray-out / mic disabled after ~30 s and could only be revived by
toggling mic mute. Root cause: SAD-079 MissedKeyUpWatchdog
subscribed to AudioTransmitGate.transmit_active and force-cleared
it after 30 s of true. In PTT mode this is correct (stuck key =
bug). In Continuous mode transmit_active is *supposed* to stay
true indefinitely; the watchdog assumption doesn't hold.

Fix: the watchdog now subscribes to a new ptt_held watch on the
TransmitModeSelector (the raw key-state input, not the resolved
gate). In Continuous mode ptt_held is never set true, so the
watchdog never fires. In PTT mode it still fires on a stuck
key-down as before. The session owns the watchdog (was on the
engine) so it survives engine restarts; it's spawned lazily on the
first start_audio.

MissedKeyUpWatchdog gains spawn_on_signal(rx, on_timeout, timeout)
alongside the existing spawn(gate, timeout) — old shape preserved
for backwards compat. run_watchdog generalised to take any
watch::Receiver<bool> + Box<dyn Fn() + Send + Sync>.

Two new tests:
  - watchdog_on_signal_does_not_fire_when_ptt_held_stays_false
    (the Continuous-mode regression test)
  - watchdog_on_signal_fires_when_signal_stays_true
    (the stuck-key case still fires)

Issue 2: the Voice Bar stats line said 'PTT on/off' even when the
user was in Continuous mode where no PTT key is involved. Renamed
to 'Mic on/off' (mode-neutral) and l10n-ised the on/off literal:
  - en: 'Mic on' / 'Mic off'
  - zh: '麦克风 开启' / '麦克风 关闭'

cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(was 78, +2 watchdog tests).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 00:57:10 +08:00
EdisonJwa 21945979a3 test(audio,ptt): comprehensive Windows P0 unit-test suite (L0-L11)
Layered test coverage for the Windows PTT subsystem ahead of the
v1.0.0-rc.8 official release sign-off.

L0 (refactor)
- Extract three pure-logic dispatchers from the existing WndProc /
  LowLevelKeyboardProc / LowLevelMouseProc bodies in
  crates/chanora_audio/src/ptt_backends/windows.rs:
    dispatch_raw_input(ctx, &RAWINPUT)
    dispatch_hook_keyboard(ctx, wparam, &KBDLLHOOKSTRUCT)
    dispatch_hook_mouse(ctx, wparam, &MSLLHOOKSTRUCT)
  Each takes a small Context (AtomicBinding + AudioTransmitGate +
  flags) and is callable without spinning up any Win32 plumbing.
  The real Win32 procs unchanged structurally; they unpack lparam
  and forward to the dispatchers. AtomicBinding / RawInputContext
  / HookContext / resolve_binding are now pub(crate) so the
  in-file test module can drive them.

L1 — windows_keymap full-table sweep (+13 tests)
  Every key_label_to_vk arm, all A-Z + a-z, all 0-9, F1-F20,
  navigation, modifiers, OEM punctuation, numpad. Exhaustive
  mouse_label_to_button cases including the 0x08 / 0x10 /
  unknown-bitmask fallbacks.

L2 — AtomicBinding lock-free correctness
  store/read round-trip, clear(), Default = zeros, single-writer
  / single-reader concurrency, many-readers / single-writer.

L3 — resolve_binding dispatcher tests
  All PttInputClass variants, well-known labels, unknown-label
  fallback, mismatched class+label rejection, mouse bitmask
  resolution.

L4 — Backend state-machine
  Both WindowsRawInputBackend and WindowsHookBackend:
  descriptor() pre-arm vs post-arm (L0Focused -> L2/L3), start()
  with None binding rejection, rebind() in-place, stop()
  clears + idempotent, stop() after stop() no-op.

L5 — dispatch_raw_input table
  Keyboard match/non-match, key-down/key-up via Flags & 0x01,
  no-binding short-circuit, mouse XBUTTON1/XBUTTON2 down/up
  matching the bound button, unhandled HID type. RAWINPUT structs
  built via mem::zeroed plus field-fill, owning the unsafe in
  the test layer where it belongs.

L6 — dispatch_hook_keyboard + dispatch_hook_mouse
  WM_KEYDOWN / WM_KEYUP / WM_SYSKEYDOWN / WM_SYSKEYUP for the
  keyboard path, WM_XBUTTONDOWN / WM_XBUTTONUP for the mouse
  path. Same shape as L5.

L7 — Privacy invariant (crates/chanora_audio/tests/ptt_privacy.rs)
  New cross-platform integration test installs a custom
  tracing_subscriber Layer that records every emitted event's
  target + field names. Exercises the public PTT API plus (on
  Windows) the backend factory. Asserts no field name in the
  banned list (vk, scan_code, keysym, key_label, bound_key,
  binding, platform_key, VKey, wVk, wScan, kbflags, mouseflags)
  is ever emitted and every field belongs to the DEC-027
  allow-list. Adds tracing-subscriber as a dev-dependency on
  chanora_audio.

L8 — Full-chain integration in core/chanora_core/src/ptt.rs
  Windows-only mod windows_full_chain_tests:
    zero-tail full chain (synchronous)
    default-tail full chain (200 ms wait then off)
    mid-press rebind abandons in-flight press

L9/L10/L11 — tools/windows-smoke.cmd + tools/windows-smoke.md
  Batch smoke script + operator doc. cargo build, flutter build,
  artifact existence + size checks, headless launch with stderr
  capture, bridge-initialised log assertion. Distinct exit codes
  per failure step. Doc explains invocation + common failure
  modes.

Verification (Linux)
- cargo check --workspace: clean.
- cargo test --workspace: 78 passed / 0 failed / 3 ignored.
  76 cross-platform unit tests (unchanged) plus the new
  ptt_privacy integration test plus one new ignored portal smoke
  test.

The Windows-gated tests (~49 new) compile and run on the Korean
Windows 11 host where they belong; cross-compile from Linux is
not configured locally. The smoke script is the production
acceptance gate for rc.8 on Windows.

Deviations from the original plan are minor (single ignored
real-runtime test rather than per-platform attribute, L7 uses
public API rather than pub(crate) dispatchers, dispatchers live
inside windows.rs rather than a sibling module) and documented
in the subagent report.
2026-05-16 00:47:10 +08:00
EdisonJwa 7596f8a9dc fix(ui,voice): display 'Space' (not blank) in bind dialog; cut PTT poll latency
Issue 1: tapping Space in the PTT binding capture dialog set
_captured to LogicalKeyboardKey.space.keyLabel which is ' ' (a single
space character), rendering as a blank string in the 'Captured: '
display. Same problem for Enter, Tab, Backspace, etc. — Flutter's
keyLabel returns the printable representation, not a readable name.
Added _displayLabelForKey() with a table that maps whitespace and
common special keys to canonical English labels matching the
entries in crates/chanora_audio/src/ptt_backends/windows_keymap.rs
(so the bridge resolves to the right VK_* on Windows). Pure
modifier keys (shift / ctrl / alt / meta / caps / num / scroll
lock) return null so they don't accidentally bind on their own.

Issue 2: physical key press -> 'PTT=on' in the Voice Bar lagged by
up to ~500 ms because audioStats was polled at 500 ms intervals.
The Rust-side transition is microsecond-fast; the visible delay is
purely the Flutter poll interval. Cut to 80 ms (~12 Hz), well below
the perceptual lag threshold. Adds ~12 small FFI calls per second,
trivially cheap. A push-based BridgeEvent::TransmitActiveChanged
would let us drop the poll entirely; noted as a follow-up.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 00:31:57 +08:00
EdisonJwa 87cff52c0a test(ptt): acceptance tests for press-on / release-off shape
Two new tests covering the user-acceptance criterion 'press → ptt
on, release → ptt off' through the full pipeline (backend press_gate
→ edge watcher → release tail → selector → real gate, identical to
what audioStats.pttActive reads in production):

  * press_on_release_off_zero_tail — release_tail_ms = 0, transitions
    are synchronous modulo one tokio tick.
  * press_on_release_off_default_tail — release_tail_ms = 200,
    transmit stays on briefly past key-up then transitions off.

cargo test --workspace --lib: 76 passed / 0 failed / 1 ignored.
2026-05-16 00:21:58 +08:00
EdisonJwa 82b99ebcff feat(ui,voice): add speaker mute button to VoiceBar
Speaker (output) mute existed in the legacy _AudioControls widget
and the rust.setOutputMuted bridge call but was lost when SDD-097
replaced _AudioControls with VoiceBar. Mic mute carried over;
speaker mute did not.

Wire it back: VoiceBar gains an outputMuted prop + onToggleOutputMute
callback and renders a headset/headset_off icon next to the
existing mic mute. main.dart wires the existing _toggleOutputMute
handler (previously dead-code with // ignore: unused_element). The
bridge call setOutputMuted already does both effects together:
local engine silencer + server-broadcast ClientOutputMuted flag.

l10n: rename voiceHardMuteLabel to 'Mute microphone'/'麦克风静音'
to distinguish from the new voiceOutputMuteLabel 'Mute speakers'/
'扬声器静音'.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 00:18:18 +08:00
EdisonJwa 33d80894d0 feat(ptt,audio): wire PTT key edges through ReleaseTailTimer (SDD-096)
Previously the PttController handed its real AudioTransmitGate to
the platform backend and the backend wrote transmit_active directly
on every key edge — bypassing the 200 ms release tail and the
TransmitMode selector entirely. The tail timer was constructed and
exposed on ChanoraSession but never received any input, so SDD-096
and SRS-206 were spec-only.

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

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

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

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

cargo test --workspace --lib: 74 passed / 0 failed / 1 ignored
(+2 new tests vs. the previous 72).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 00:05:54 +08:00
EdisonJwa 64878e3a7d fix(ui,voice): consolidate to single Voice settings entry point
The capability badge had its own 'Configure' TextButton that opened
the bind-key flow, while the Voice Bar's settings gear also reached
bind-key through the settings dialog. Two paths, same destination —
confusing and pointless duplication that the user flagged.

Resolution: the gear is the only configuration entry point. The
capability badge becomes information-only — it still shows the
detected PTT level + backend and (for L0Focused) the info-icon
explanation sheet, but no Configure button. The badge no longer
takes  or  props. The Voice Bar drops
the  callback added in 6a41a0b.

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

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

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-15 23:52:23 +08:00
EdisonJwa 8cd919cffc fix(audio,storage,ui): allow PTT binding before audio is running
Save-binding before joining a voice channel used to return
BridgeError.invalidCommand(audio not started) because the
PttController only exists after start_audio runs and
set_ptt_binding required a live controller. Users naturally want
to bind their PTT key once on first launch, not every time they
join a channel — fix:

* chanora_storage::IdentityFileStore::set_ptt_binding /
  get_ptt_binding persist the privacy-safe binding triple
  (input_class, platform_key, key_label) into audio_meta.json
  next to transmit_mode and release_tail_ms.
* ChanoraSession holds pending_binding: Arc<Mutex<Option<PttBinding>>>.
  set_ptt_binding now (1) persists to storage best-effort, (2)
  stashes into pending_binding, (3) forwards live to the
  controller only if one exists. No more AudioNotStarted.
* init_storage loads the persisted binding into pending_binding
  so it survives app restarts.
* start_audio applies pending_binding immediately after constructing
  the PttController so the first key-press after join already works.
* supervisor_loop carries pending_binding and re-applies it after
  any reconnect-driven audio engine restart, so reconnects don't
  silently drop the hotkey.
* New bridge call get_ptt_binding() -> (input_class, key_label) plus
  a matching Flutter _hydratePttBinding() in initState lets the
  Voice Bar show the user's saved hotkey label on launch (e.g.
  'PTT: Space') before any voice channel is joined.

cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
FRB bindings regenerated.
2026-05-15 23:45:20 +08:00
EdisonJwa 6a41a0b4db fix(ui,voice): five Voice Bar / settings UX bugs
1. Hard-mute now informs the server (setInputMuted) in addition to
   clamping the local TransmitGate. Without the server-side flag,
   other clients keep seeing us un-muted; without the local clamp
   a beat of in-flight audio leaks through. Drive both together so
   the mic icon and the actual silence land at the same time.

2. Split the badge's Configure affordance from the Voice Bar's
   'Voice settings' gear. The gear opens the mode + release-tail
   dialog (onConfigure); the badge's configure opens the bind-key
   capture flow directly (new onBindKey). Previously both routed
   to the settings dialog, so 'Voice settings' and the badge's
   'Configure' were the same screen — useless duplication.

3. Bind-key label is now PTT-only. The mode-badge row no longer
   prints 'PTT: Space' when Continuous / Voice Activity is
   selected. A new PTT-only secondary line carries the bound key
   plus the release-tail value together, hidden entirely for
   non-PTT modes.

4. Release-tail row is now PTT-only in BOTH the Voice Bar and the
   Voice settings dialog. The dialog previously kept the slider
   visible across all modes; switching to Continuous left the
   user staring at a control that did nothing.

5. PTT capability badge is now PTT-only. In Continuous and Voice
   Activity modes there is no key binding to surface a capability
   for, so the 'L0Focused (focused)' line + its info sheet and
   the Configure button disappear from the Voice Bar when the
   user isn't in PTT mode.

All five fixes are pure UI; no Rust changes needed. flutter analyze
remains clean (6 pre-existing Radio.groupValue deprecation infos).
2026-05-15 23:34:53 +08:00
EdisonJwa 7f4874f4c0 chore(bridge): regenerate FRB bindings for log_file_path_str 2026-05-15 23:25:50 +08:00
EdisonJwa 6a077ac7a1 feat(bridge): tee tracing output to platform log file + log_file_path_str API
Bridge already had a tracing fmt layer writing to stderr but a Flutter
desktop app launched from Explorer / RDP has no terminal attached so
those records vanish. Add a non-ANSI file appender (best-effort,
4 MiB rotation) at the platform-conventional log path so developers
and beta testers can hand-inspect output:

  * Linux:   $XDG_STATE_HOME/app.chanora/chanora_flutter/chanora.log
              (fallback ~/.local/state/...)
  * macOS:   ~/Library/Logs/app.chanora.chanora_flutter/chanora.log
  * Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log

Expose log_file_path_str() over FRB so the UI can show the path in a
'Save diagnostics' affordance later. Mobile (Android/iOS) returns an
empty string — those platforms still rely on logcat / Console.app.

No DEC-016 conflict: this is local-only, append-only, never
auto-uploaded. The in-memory log sink and export_diagnostics() path
are unchanged. The redacting layer still wraps the in-memory sink;
the new file appender consumes the same tracing events post-filter.

Trigger for this change: a ko-KR Windows 11 tester saw
'BridgeError.invalidCommand(audio not started)' with no way to find
the upstream warn record that documents which cpal call failed. Log
file is now discoverable without a terminal launch.
2026-05-15 23:18:57 +08:00
EdisonJwa ba444d94bd feat(audio,bridge,flutter): v1 audio + PTT lifecycle implementation (SDD-094..097)
Implement the SDD-094 / SDD-095 / SDD-096 / SDD-097 detailed designs
committed in dfa84ee.

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

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

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

Follow-up (intentionally deferred)
- PttController and per-platform PTT backends still drive AudioTransmitGate
  directly via the legacy set_ptt path; routing those key edges through
  ChanoraSession::release_tail_timer().{key_down,key_up} so the tail
  applies to native PTT input is a contained wiring change in a follow-up.
- Real audio-level RMS in BridgeAudioStats (current meter is binary).
- VoiceActivity backend (DEC-030).
2026-05-15 23:05:37 +08:00
EdisonJwa dfa84ee7bb docs(spec): baseline 0.9.5 — v1 audio + PTT lifecycle redesign
Add SysRS-303/304, SysDes-149/150/151, SRS-204/205/206/207,
SAD-081/082/083, SDD-094/095/096/097, DEC-029/030.

Captures the v1 lifecycle redesign:
- Drop manual Start-audio button; audio engine is bound to voice-channel
  join/leave (ensure_running on first join, shutdown_if_idle on last
  leave). Output stream opens regardless of mic-permission state so
  listen-only is a first-class flow.
- TransmitMode enum (Ptt / Continuous / VoiceActivity-reserved).
  Default Ptt on fresh install. Persisted per identity.
- PTT release tail: 200 ms default (0-500 ms configurable) before
  transmit gate closes, avoiding clipped trailing syllables.
- Hard-mute toggle overrides transmit gate regardless of mode/PTT.
- Bridge surface: drop start_audio/stop_audio; add
  voice_join(channel_id) / voice_leave() and BridgeEvent::VoiceState.

DEC-029 rejects Flutter global-hotkey packages (hotkey_manager,
super_hot_key) for PTT: they wrap RegisterHotKey/RegisterEventHotKey
which consume the key and don't fire key-up, wrong primitive for PTT.
Native Rust DesktopPttBackend (SDD-083/084/085) stays authoritative.

DEC-030 defers Voice Activity Detection to P1. RMS / WebRTC VAD /
Silero VAD trade-off review (binary-size, dependency-surface, CPU
profile) postponed; TransmitMode::VoiceActivity reserved on the enum
surface so a P1 increment is non-breaking.

Validator clean: 304/151/207/83/97 IDs, strict layered sourcing
preserved, no new warnings beyond the pre-existing 35 old-package-name
filenames.
2026-05-15 22:46:51 +08:00
EdisonJwa f9d20d8585 fix(audio,windows): adapt Raw Input + hook backends to windows-rs 0.54 API shape
Initial Task C commit (77c2a1d) used handle constructors and import paths
that match windows-rs 0.58+, not the 0.54 version pinned via the
workspace's transitive 'windows' dep. Compile errors on the Korean
Windows 11 build:

  * HWND/HHOOK/HRAWINPUT take 'pub isize' in 0.54 (became raw pointers
    in 0.58). Use HWND(HWND_MESSAGE_PTR), HHOOK(0), HRAWINPUT(lparam.0)
    instead of *mut _ casts.
  * CreateWindowExW returns HWND directly in 0.54, not Result<HWND>.
    Check hwnd.0 == 0 for null.
  * RegisterClassExW + WNDCLASSEXW are gated behind Win32_Graphics_Gdi
    in 0.54. Added that feature.
  * XBUTTON1 / XBUTTON2 live in Win32::UI::WindowsAndMessaging not
    Win32::UI::Input::KeyboardAndMouse in 0.54.
  * Win32_System_Threading needed for GetCurrentThreadId.
  * Unused Mutex import dropped.

No behavioural change relative to 77c2a1d; just signature alignment.
2026-05-15 22:08:02 +08:00
EdisonJwa 77c2a1def4 feat(audio,windows): real Raw Input + low-level hook global PTT (SDD-083 / SDD-084)
The v1.0.0-rc.7 Windows backends were thread::sleep stubs that
reported optimistic L2GlobalHoldToTalk / L3GlobalWithMouseButtons
descriptors without actually registering for any global key events.
Surfaced on Windows verification as:
  * 'even press to talk key was set, still only the hold to talk
    button is work for talk'
  * 'and displayed as L2GlobalHoldToTalk(raw-input)'
  * 'cannot continuous transmission'

This commit implements the real backends:

  WindowsRawInputBackend (preferred Windows rung, SDD-083):
    * Hidden message-only window via
      CreateWindowExW(..., HWND_MESSAGE, ...).
    * RegisterRawInputDevices with RIDEV_INPUTSINK on
      Usage Page 0x01 / Usage 0x06 (keyboard) and 0x02 (mouse) so
      events fire globally — including when Chanora is unfocused.
    * WndProc handling WM_INPUT: GetRawInputData ->
      keyboard.VKey vs bound vk, or mouse.usButtonFlags vs bound
      side-button index. Down -> gate.set(true); up -> gate.set(false).
    * Dedicated chanora-rawinput thread runs GetMessageW /
      TranslateMessage / DispatchMessageW until stop() posts
      WM_QUIT via PostThreadMessageW.

  WindowsHookBackend (fallback rung, SDD-084):
    * SetWindowsHookExW(WH_KEYBOARD_LL) + WH_MOUSE_LL on a
      dedicated chanora-llhook thread.
    * Hook procs translate KBDLLHOOKSTRUCT.vkCode and
      MSLLHOOKSTRUCT.mouseData against the same shared
      AtomicBinding.
    * UnhookWindowsHookEx on teardown.

  Both backends:
    * Honest descriptor() reporting: backends start reporting
      L0Focused; level upgrades to L2 / L3 only after a real
      arming success (RegisterRawInputDevices or SetWindowsHookEx
      returning Ok). This fixes the 'L2 reported but doesn't fire'
      complaint by making the badge tell the truth — if Raw Input
      registration fails at runtime the user sees the L0Focused
      info-icon explanation sheet instead of being told L2 works.
    * AtomicBinding (class / vk / mouse_btn) for lock-free hot
      path. Translation lives in
      crates/chanora_audio/src/ptt_backends/windows_keymap.rs
      which maps Flutter LogicalKeyboardKey.keyLabel strings
      (e.g. 'Space', 'F10', 'A') to Win32 VK_* codes; mouse
      side-button bitmask strings ('mouse-side-button:8' /
      ':16') to RawInput button indices (4 / 5).
    * Per-thread context (thread_local RefCell) carries the
      gate + binding to the WndProc / hook proc without needing
      raw-pointer user-data plumbing.

  Diagnostic logging:
    * AudioEngine::start now logs default_input_config and
      default_output_config explicitly with the channels /
      sample_rate / sample_format that cpal reports, so a
      build_*_stream failure on locale-specific Windows hosts
      (reported on ko-KR Windows 11 as 'Start Audio Button not
      work') becomes diagnosable from the stderr log alone.
    * build_output_stream surfaces the requested config in the
      tracing::error! record on failure.

  Privacy (DEC-027 / SDD-090): the windows.rs and
  windows_keymap.rs hot paths NEVER log raw VKs, scan codes,
  keysyms, key labels, or button identifiers. Only the
  platform-neutral input class ('keyboard' /
  'mouse-side-button') and the backend id appear in the tracing
  stream. The SDD-090 PttSanitizer Layer is the defence-in-depth
  net but this code does not rely on it.

  Tests: 4 new windows-only unit tests in windows_keymap (ASCII
  letters / digits / Space + Fn / unknown / mouse button index).
  They compile only under cfg(target_os = "windows") so the
  Linux workspace test count is unchanged at 59/0/3.

  Cargo deps: adds windows = '0.54' (target_os = windows) with
  the feature set needed for RawInput + hooks. 0.54 matches
  the version already transitive through the workspace.

Verified on Linux: cargo check --workspace clean, cargo test
--workspace 59/0/3 (windows-gated tests skip on Linux). The
real exercise of this commit will happen on the Korean Windows
11 host (100.84.219.45) at the next build.
2026-05-15 22:01:28 +08:00
EdisonJwa 8e04a1e2a6 fix(storage): file is durable DEK source; keyring is accelerator only
Surfaced on the v1.0.0-rc.7 Windows verification round as 'Bridge
Error connection failed: storage crypto decrypt aead error'.

Root cause: IdentityFileStore::ensure_dek treated the platform
keyring as the authoritative store and deleted identity.dek after
successfully promoting it. Subsequent launches whose process
context could not reach the keyring (Windows SSH session hits
ERROR_NO_SUCH_LOGON_SESSION; macOS LaunchAgent contexts hit
errSecMissingEntitlement) saw dek_path.exists() = false and
generated a fresh DEK, even though the keyring still held the
DEK that originally encrypted identity.tskey. Next ChaCha20-
Poly1305 AEAD decrypt of the identity blob then failed because
the in-process DEK was 32 fresh random bytes, not the bytes that
encrypted the stored ciphertext. The bookmark store (which
shares the DEK via crypto()) also broke for the same reason.

Manual reproduction on the rc.7 build at 100.84.219.45:
  * Launch via SSH (keyring unreachable) -> file DEK_v1 created,
    identity.tskey eventually encrypted under DEK_v1.
  * Launch via RDP (keyring reachable) -> file DEK_v1 promoted
    to keyring, identity.dek deleted.
  * Launch via SSH again (keyring unreachable, file gone) -> a
    fresh DEK_v2 is written to file. identity.tskey still
    encrypted under DEK_v1.
  * Next decrypt: DEK_v2 vs identity.tskey ciphertext -> AEAD
    tag mismatch -> StorageError::Crypto('decrypt: \u2026') ->
    bubble up as 'storage crypto decrypt aead error'.

Fix invariants:
  * identity.dek (file) is the durable source of truth and is
    never deleted by ensure_dek.
  * keyring is opportunistic: we copy the DEK into it for the
    UX-level convenience of platform-managed secret storage,
    but its presence/absence does not affect correctness.
  * ensure_dek on first install writes the DEK to BOTH places.
  * ensure_dek on subsequent launches: keep using the file DEK;
    re-copy into the keyring if not present (idempotent).
  * load_dek prefers the file; only consults the keyring as a
    legacy-migration fallback for installs that lost their file
    mirror before this commit landed.

No SDD / SAD / SRS contract changes - the file fallback at
identity.dek and the in-keyring entry at app.chanora.identity::
identity-dek::<canonical-dir> were both already documented
behaviours; this commit corrects which one is authoritative.

The file lives in app-private storage where the platform
sandbox is the access-control authority (this was already
called out in the existing open_private comment on non-Unix
targets), so retaining the file mirror does not weaken the
security posture in any meaningful way relative to the prior
keyring-only durable-state design.

Verified on Linux: cargo test --workspace 59/0/3 (no regressions
from feceacf + 5c413ba).
2026-05-15 21:25:29 +08:00
EdisonJwa 5c413ba199 fix(protocol): sort channels by TS3 linked-list order, not by numeric value
The TeamSpeak 3 protocol's per-channel `order` field is NOT a
numeric rank — it stores the ChannelId of the channel that should
appear immediately before this one within the same parent. The
previous chanora_protocol::adapter::build_snapshot sorted by
`order.0` as if it were a sequence number, producing
stable-but-arbitrary output that did not match TS3 client display
order. Surfaced on the Windows verification round as 'channel
sort in not correct'.

Replace the numeric sort with a linked-list walk per parent
followed by a root-first depth-first emission so the bridge
consumer receives a pre-ordered tree:

  fn sort_channels_tree(&[&Channel]) -> Vec<&Channel>
  fn sort_channels_tree_by<T>(&[&T], extract) -> Vec<&T>
  fn emit_subtree<T>(by_parent, root_id, out, extract)

Defensive behaviour:
  * Per-parent cycle guard so a malformed snapshot can't infinite-loop.
  * Channels whose predecessor pointer is unreachable from
    order=0 are appended at the end of their parent bucket sorted
    by id (channel never silently disappears from the UI).
  * Channels whose `parent` is not present anywhere in the tree
    are appended at the very end sorted by id (orphan defence).

Unit tests cover the four shapes that broke real users:
  * Single-parent linked list out of HashMap iteration order
  * Disconnected predecessor (leftover-bucket fallback)
  * Two-level tree (depth-first subtree emission)
  * Two-channel cycle (no infinite loop, both channels emitted)

Also removes the now-redundant Dart-side numeric sort in
_SnapshotView.build(); Flutter trusts the pre-ordered server
list and would otherwise re-introduce the bug.

Verified on Linux: cargo test --workspace 59/0/3 (was 55 + 4 new
adapter tests), flutter analyze clean.
2026-05-15 20:38:51 +08:00
EdisonJwa feceacfdad feat(flutter): surface bound PTT key label in capability badge (SDD-091 follow-up)
After a user saved a PTT binding through _PttBindingCaptureDialog,
the capability badge showed the resolved level + backend (e.g.
'PTT: L2WindowsRawInput (windows-raw-input)') but never told the
user which key they had actually bound. Reported on the Windows
verification round as 'do you think we should tell user what key
they have set and then they will know what to press'.

This commit caches the captured platform-neutral key label in
_BetaHomeState whenever _onConfigurePtt succeeds, and threads it
through _AudioControls to a new boundKeyLabel prop on
PttCapabilityBadge. When the prop is non-empty the badge renders
a second line below the existing row:

  PTT: L2WindowsRawInput (windows-raw-input)    [ⓘ] [Configure]
    Key: Space

The label uses bodySmall + monospace + onSurfaceVariant to stay
visually subordinate to the capability descriptor. Two new l10n
entries (en + zh) cover the 'Key: {key}' string.

Privacy: the displayed label is the same platform-neutral
LogicalKeyboardKey.keyLabel string the dialog already shows
during capture and that already crosses the bridge as
PttBinding.platform_key. No raw OS key code is introduced
(DEC-027 / SDD-077 compliance preserved).

State scope: display-only cache that resets on app restart. The
bridge-side PttController (SDD-088) holds the authoritative
binding; this UI cache is purely for display continuity within
a single process.

Verified on Linux: flutter analyze clean, cargo test --workspace
55/0/3.
2026-05-15 20:35:42 +08:00
EdisonJwa 9831624079 feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)
The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:

* SDD-088 PttController — backend ownership + binding mutex +
  capability watch lived split between AudioEngine and
  ChanoraSession. Extracted into chanora_core::ptt::PttController.
  AudioEngine now owns only the cpal streams and the missed-key-up
  watchdog (SDD-092); the platform input backend, the active
  PttBinding, and the capability watch::Sender live in the
  controller. ChanoraSession::start_audio constructs the controller
  against the engine's gate; disconnect/reconnect/restart paths
  tear it down through stop().await before the engine.

* SDD-090 PttSanitizer — banned-field check was inlined as
  PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
  into a generic PttSanitizer<L> tracing_subscriber::Layer that
  decorates an inner Layer (canonical pairing:
  RedactingLogLayer::with_sanitizer). The inner layer keeps its
  own structural ban check as defence-in-depth for bare-install
  callers.

* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
  Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
  into a public PttCapabilityBadge widget and added the
  SDD-091-specified per-platform explanation sheet that opens on
  the info-icon tap when the resolved capability is L0Focused.
  New l10n strings (en + zh) cover the sheet copy.

Tests:
  * 2 new unit tests for PttController (arm + descriptor watch)
  * 1 new unit test for PttSanitizer (end-to-end through a real
    tracing subscriber proving banned drop + safe forward)
  cargo test --workspace: 55 passed / 0 failed / 3 ignored
  cargo deny check: advisories ok, bans ok, licenses ok, sources ok
  flutter analyze: no issues
  tools/validate_docs.py: zero undefined refs, zero direct-layer
    violations (pre-existing 35 old-package-name warning unchanged)

No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
2026-05-15 17:45:03 +08:00
EdisonJwa 63f2901a6a docs(traceability): close SRS-200 SAD/SDD coverage gap (P0 audit follow-up)
A P0 traceability audit per the project's compliance workflow
found exactly one gap across the 162 P0 SRS items:

  * SRS-200 (mouse side buttons as bindable inputs for desktop
    Global PTT) had no dedicated SAD item. The earlier baseline
    matrix folded SRS-200 under the narrative
    `SRS-195..203 -> SAD-071..079` umbrella, which technically
    covered the requirement at the table level but did not give
    SRS-200 a one-to-one SAD source the validator's strict
    discipline expects.

Per the project's gate-check workflow (Case C —
BLOCKED_MISSING_SAD) this commit closes the documentation chain
*before* claiming compliance for the already-merged mouse-side-
button code path:

  * `docs/architecture/sad.md` v0.9.4 — new `SAD-080` sources
    SRS-200, allocates `Audio (Windows / macOS / Linux), Bridge,
    Flutter UI`, and records the cross-platform mouse-side-button
    surface as a software-architecture item. The §27 coverage
    matrix gains a dedicated SRS-200 -> SAD-080 row.
  * `docs/architecture/sdd.md` v0.9.4 — new `SDD-093` sources
    SAD-080. Specifies the `PttInputClass` enum surface
    (`None` / `Keyboard` / `MouseSideButton`), the rebind
    contract on the Windows + macOS backends, the Linux portal's
    pass-through behaviour, and the Flutter
    `PointerEvent.buttons` bitmask capture (back = `0x08`,
    forward = `0x10`). The §11 coverage matrix gains the
    SAD-080 -> SDD-093 row.
  * `docs/governance/traceability-matrix.md` v0.9.4 — the
    `(DEC-026 mouse buttons)` row moves from
    `SAD-072..074 / SDD-082..086` to the dedicated
    `SAD-080 / SDD-093`.
  * `docs/governance/baseline-candidate-validation-report.md`
    v0.9.4 — ID totals advance to 302 / 148 / 203 / 80 / 93;
    direct-layer-rule and undefined-reference counts remain
    zero.
  * `docs/governance/repo-format-validation-report.md` v0.9.4 —
    same ID totals update.

Audit summary
-------------

* 162 P0 SRS items audited.
* 1 SAD-coverage gap (SRS-200) — closed by this commit.
* 0 SDD-coverage gaps (every PTT SAD has explicit SDD coverage;
  the inherited baseline `SAD-032/033` are covered through the
  documented range row, not individually).
* All Priority: P0 software requirements now have a strict
  SRS -> SAD -> SDD chain on file.

Validator output:

```
[OK] SysRS: 302 defined, 0 undefined references
[OK] SysDes: 148 defined, 0 undefined references
[OK] SRS: 203 defined, 0 undefined references
[OK] SAD: 80 defined, 0 undefined references
[OK] SDD: 93 defined, 0 undefined references
[OK] SRS direct SysRS references: 0
[OK] SAD direct SysRS references: 0
[OK] SAD direct SysDes references: 0
[OK] SDD direct SysRS references: 0
[OK] SDD direct SysDes references: 0
[OK] SDD direct SRS references: 0
```

Implementation status
---------------------

The mouse-side-button code was implemented in v1.0.0-rc.4 and
v1.0.0-rc.5 under the (then-implicit) PTT umbrella; the code
already matches the new SDD-093's contract verbatim. This
commit ships **documentation only** — it adds the SAD/SDD/matrix
rows that retroactively justify the existing implementation
under the strict traceability discipline. No code edits, no test
edits.

  * `cargo test --workspace`: 67/67 green (unchanged).
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate`: zero new warnings (no Cargo.lock
    delta).
  * `flutter analyze`: clean.
  * `tools/validate_docs.py`: all SRS/SAD/SDD coverage and
    direct-layer-rule checks pass.

Outstanding open items remain live verification per
`docs/release/release-readiness-go-nogo-record.md`
(RR-PTT-001..006/008) and the DEC-012 legal review; both are
non-engineering work.
2026-05-15 17:02:26 +08:00
EdisonJwa 03f5d6bca3 fix(p0): close three P0 coverage gaps after rc.5 audit
Audited every `Priority: P0` row in `docs/requirements/{sysrs,srs}.md`
against the live code. Three items needed work; this commit closes
all three.

Gap A — SysRS-262 + SysRS-282 (screen-reader semantics + accessible
labels for the PTT control)
-------------------------------------------------------------------

The Flutter PTT control is a custom `Listener` over a `Container`
— not a built-in `Button`, so the platform accessibility tree had
no idea it was an interactive control. Screen readers
(VoiceOver, TalkBack, NVDA, Orca) would have read the visible text
without announcing the control role or its toggled state.

Wrap the Listener in a `Semantics(button: true, toggled: _pressed,
label: …, hint: …, excludeSemantics: true)` so the platform
accessibility tree carries the right role, the current state
("Hold to talk" / "Transmitting"), and a usage hint. The
`excludeSemantics: true` argument suppresses the duplicate child
nodes the Container + Row + Icon + Text would otherwise generate
on top of our explicit label.

SysRS-263 (no colour-only state) is preserved: the visible label
and the mic icon already differentiate the two states without
relying on the colour transition.

New ARB key `pttHoldToTalkSemanticsHint` in `app_en.arb` and
`app_zh.arb`.

Gap B — SRS-198 (macOS async permission re-check)
-------------------------------------------------

The macOS backend queried `query_permission()` once at
construction and never re-checked. That violates SRS-198's
"upgrade to the appropriate Global level only after the user
grants the required permission" — once Chanora is running, a
runtime grant must lift the descriptor from `L0Focused` to a
Global level without an app restart.

Substantive rewrite of `crates/chanora_audio/src/ptt_backends/macos.rs`:

  * `permission: PermissionState` becomes `permission: Arc<AtomicU8>`,
    enabling cross-thread updates without a Mutex.
    `PermissionState::{to_u8, from_u8}` carry the encoding.
  * The backend owns a `tokio::sync::watch::Sender<PttBackendDescriptor>`
    and overrides `DesktopPttBackend::descriptor_watch()` to hand
    out subscribers; `chanora_core::ChanoraSession::start_audio`
    already forwards transitions to `SessionEvent::PttCapability`.
  * `start()` spawns a `chanora-perm-watch` OS thread that polls
    `query_permission()` every 1.5 s and republishes the
    descriptor on every transition. Polling rather than KVO /
    notifications because Input-Monitoring has no public
    change-notification API on macOS; 1.5 s is sufficient for a
    user grant + return-to-Chanora cycle.
  * `rebind()` also republishes the descriptor so a
    `keyboard → mouse-side-button` change updates the badge.
  * Six new unit tests on the platform-independent
    `build_descriptor` and the atomic encoding contract. They
    only compile under `target_os = "macos"` (consistent with
    the rest of the module), so the Linux dev-host workspace
    test count is unchanged.

`query_permission()` itself still returns `Undetermined` until
the IOKit live link lands in the macOS platform-verification
commit; the re-query loop will engage the upgrade path
automatically the moment that function returns real values.

Gap C — SRS-200 (Linux mouse-side-button portal-dependence)
-----------------------------------------------------------

`desktop-ptt-architecture.md` §5.3 already described the
heuristic classifier. Added one explicit sentence stating that
Linux mouse-side-button support is *portal-dependent*: Chanora
never claims a fixed Mouse4/Mouse5 binding on Linux; the portal
decides what inputs it accepts in the current session, and the
classifier degrades to `keyboard` whenever the portal's
description does not contain "mouse". This matches the SRS-200
text verbatim and removes the ambiguity over what "Linux
support follows the portal" means in practice.

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

  * `cargo test --workspace` (with `CHANORA_DISABLE_KEYRING=1`):
    all 67 Linux-side tests green (unchanged). The new macOS
    unit tests count under `target_os = "macos"` only — they
    will report once the macOS reference host runs `cargo test`.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `flutter analyze`: clean (no new accessibility warnings).
  * Linux release bundle builds clean.

P0 audit summary
----------------

After this commit every Priority: P0 row in `sysrs.md` and
`srs.md` has a concrete implementation. The remaining open items
are all live verification, not code:

  * Per-platform live PTT traces on Windows / macOS reference
    hosts (RR-PTT-001..003, RR-PTT-008) — hosts unavailable
    locally; queued for platform owners.
  * Linux GNOME-Wayland live trace (RR-PTT-004) — implemented
    in rc.5; awaiting live host trace.
  * Linux non-tested compositor fallback trace (RR-PTT-005) —
    Open.
  * Diagnostic-export key-leak inspection (RR-PTT-006) — Open
    but trivially testable on any host with PTT bound.
  * DEC-012 legal review — engineering hand-off complete since
    rc.2.
2026-05-15 16:50:48 +08:00
EdisonJwa 82d012a46b feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025)
Promotes the Linux backend from probe-only to a live
`org.freedesktop.portal.GlobalShortcuts` session, closing the
gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I
flagged on the review pass are addressed:

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

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

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

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

Trait surface (cross-cutting):

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

`Cargo.toml` (Linux-only):

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

Tests
-----

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Flutter
-------

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

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

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

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

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

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

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

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

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

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

Tests
-----

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

No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
2026-05-15 15:02:03 +08:00
EdisonJwa 02ffadfa52 docs(ptt): land Baseline Candidate v0.9.3 — capability-based desktop PTT
Applies the gen2 desktop-PTT review summary
(`gen2/chanora-desktop-ptt-review-summary-v0.9.2.md`) to our doc set
with the owner rulings PTT-OPEN-001 through PTT-OPEN-006 resolved as
accepted decisions DEC-023 through DEC-028:

  * DEC-023 Windows Global PTT P0 / MVP
  * DEC-024 macOS Global PTT P0 / MVP with permission UX
  * DEC-025 Linux officially-tested env: GNOME on Wayland only
  * DEC-026 Mouse side buttons supported (Win + macOS; Linux portal)
  * DEC-027 PTT diagnostics: capability + availability only, no
            raw key codes ever
  * DEC-028 Missed-key-up watchdog: P0

Requirements (SysRS / SRS) and architecture (SysDes / SAD / SDD)
gain the desktop-PTT ID set the gen2 summary describes:

  SysRS-296..302  -> SysDes-142..148
                  -> SRS-195..203
                  -> SAD-071..079
                  -> SDD-081..092

ID totals advance from 295 / 141 / 194 / 70 / 80 to 302 / 148 / 203
/ 79 / 92. The strict layered sourcing rule (`SRS -> SysDes` only,
`SAD -> SRS` only, `SDD -> SAD` only) is preserved; the
`tools/validate_docs.py` validator reports zero undefined refs and
zero direct-layer-rule violations.

New document:

  * `docs/architecture/desktop-ptt-architecture.md` — capability
    ladder (L0Focused, L1GlobalShortcut, L2GlobalHoldToTalk,
    L3GlobalWithMouseButtons, L4DeviceAware reserved), Windows /
    macOS / Linux strategies, privacy rule, audio-gate rule,
    missed-key-up watchdog, release-readiness evidence requirement,
    traceability summary.

Doc addenda (Baseline Candidate 0.9.3):

  * `privacy/privacy-policy.md` — no raw key history, capability-
    dependent Global PTT, UI reflects actual runtime capability
  * `security/threat-model.md` — THREAT-PTT-001..006
  * `security/diagnostic-redaction-audit-report.md` —
    REDACT-PTT-001..006 banned field list enforced by `PttSanitizer`
  * `release/platform-release-policy.md` — per-platform evidence
    fields, no over-claim on untested Linux compositors
  * `release/release-readiness-go-nogo-record.md` — RR-PTT-001..008
    release-readiness items
  * `verification/swe4-unit-verification-plan.md` —
    SWE4-UV-035..039
  * `verification/swe5-software-integration-verification-plan.md` —
    SWE5-IV-015
  * `verification/swe6-software-verification-plan.md` — SWE6-SV-017
  * `verification/sys4-system-integration-verification-plan.md` —
    SYS4-SIV-016
  * `governance/traceability-matrix.md` — full PTT trace rows +
    verification map
  * `governance/decision-impact-assessment.md` — DEC-023..028
    impact matrix
  * `governance/product-decision-register.md` v0.9.9 entry
    recording DEC-023..028 in the decision table and the status
    table at §7
  * `governance/document-index.md` — adds
    `desktop-ptt-architecture.md` to the controlled set
  * `architecture/proof-of-concept-plan.md` —
    PoC-PTT-001..005 platform items
  * `references/external-references.md` — Windows Raw Input,
    macOS event-tap, Linux GlobalShortcuts portal references
  * Both validation reports
    (`baseline-candidate-validation-report.md`,
    `repo-format-validation-report.md`) bumped to v0.9.3 with the
    new ID totals (302 / 148 / 203 / 79 / 92).

README §"Desktop Push-to-Talk" added between Architecture Overview
and Repository Layout: capability levels, per-platform strategy,
privacy posture, missed-key-up watchdog.

Tooling:

  * `tools/validate_docs.py` copied from the gen2 zip into the
    repo tree (was previously available only inside the zip).
    Reports zero undefined refs, zero direct-layer-rule violations,
    English-only CJK check passes. The 35 "old package-style
    filename" hits are pre-existing and identical to the gen2
    baseline (they live in `path-migration-map.md` and config-ID
    headers of governance docs and are intentional per the path
    migration policy).
  * `.gitignore` adds `/gen2/` so the externally-provided review
    package does not enter the repo.

No code changes in this commit; B (the implementation split into
`transmit_active` / `capture_active`, `PttCapabilityLevel`
reporting, `PttSanitizer` diagnostics rule, and the UI capability
badge) follows in a separate commit.
2026-05-15 14:51:22 +08:00
EdisonJwa b932dc1405 feat(legal): land cargo-about + cargo-deny + Flutter license inventory
Closes engineering deliverables 1–3 from the open-work table in
`docs/governance/legal-review-readiness.md` so the DEC-012 legal
review can actually run. With this commit, the only remaining
engineering item blocking sign-off is signed Windows / macOS / iOS
build artefacts, deferrable per the DEC-002 staged release plan.

Tooling
-------

* `about.toml` + `about.hbs` + `about-md.hbs` configure cargo-about
  with the DEC-020 license posture and the five-target matrix
  (Linux, Android, Windows, macOS, iOS). One per-crate clarification
  for `allo-isolate` (`flutter_rust_bridge` transitive that ships
  Apache-2.0 via `license-file` rather than an SPDX `license`
  field). `cargo about generate` runs with zero warnings.
* `deny.toml` mirrors the cargo-about allow-list and adds minimal
  bans / sources / advisories config. `cargo deny check` reports
  `advisories ok, bans ok, licenses ok, sources ok` for the
  workspace; multiple-versions of `windows_x86_64_msvc` produce
  advisory `warn` (no fail) because three windows-targets versions
  reach the graph via `jni`, `cpal`, and `keyring` respectively.
* `tools/dump_flutter_licenses.sh` + `tools/dump_flutter_licenses.dart`
  walk `apps/chanora_flutter/pubspec.lock`, resolve each dependency
  to its local pub-cache directory, read the LICENSE file, and emit
  `docs/security/flutter-license-inventory.md`. SDK-sourced
  packages (`flutter`, `flutter_localizations`, `flutter_test`,
  `flutter_web_plugins`, `sky_engine`) resolve to the Flutter
  framework BSD-3-Clause LICENSE under `$FLUTTER_ROOT` (or
  `$HOME/sdks/flutter`).

Artefacts
---------

* `docs/security/license-inventory.md` — 364 transitive Rust
  crates with full license texts. Apache-2.0 (276), MIT (55),
  Unicode-3.0 (19), BSD-3-Clause (7), ISC (7). Zero copyleft.
* `docs/security/license-inventory.html` — same data rendered as
  styled HTML for reviewer convenience.
* `docs/security/flutter-license-inventory.md` — 94 Dart / Flutter
  packages with their LICENSE texts. Zero packages without a
  resolvable LICENSE in this RC.

CI
--

* New `supply-chain` job runs `cargo deny check --workspace
  --all-features` via `EmbarkStudios/cargo-deny-action@v2`. Fails
  the build on any GPL / LGPL / AGPL / commercial-source license
  surfacing transitively.
* New `license-inventory` job installs `cargo-about --features cli`
  and regenerates `docs/security/license-inventory.md`; diffs
  against the committed copy and fails on drift. Forces
  contributors who touch the Cargo.lock to refresh the inventory.
* New `flutter-license-inventory` job runs
  `tools/dump_flutter_licenses.sh` against the just-resolved pub
  cache; same diff-on-drift semantics.

Governance
----------

* `docs/governance/legal-review-readiness.md` §5 cross-links the
  three new artefacts in a "Reviewer artefacts" subsection.
* The open-work table at the bottom of the doc is rewritten as a
  status grid: items 1–3 now read **Done**; item 4 (signed iOS /
  macOS builds) remains the only open engineering blocker, with a
  pointer back to `staged-release-plan.md`.

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

* `CHANORA_DISABLE_KEYRING=1 cargo test --workspace`: all 49 unit
  + integration tests green (unchanged from v1.0.0-rc.1).
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate --output-file …`: zero warnings.
* `tools/dump_flutter_licenses.sh`: 94 packages, 0 without LICENSE.
* `flutter analyze`: clean.

No code changes touch the runtime; this is governance-tooling only.
2026-05-15 14:00:16 +08:00
EdisonJwa 50768a8f48 feat(mvp): v1.0.0-rc.1 — keyring-backed DEK, encrypted bookmarks, MVP release-gate docs
Closes the v0.4 dual-file weakness in identity-at-rest and turns the
release into an MVP public release candidate. The remaining work
before `v1.0.0` is DEC-012 legal sign-off — see
`docs/governance/legal-review-readiness.md` — and the staged
platform promotions in `docs/governance/staged-release-plan.md`.
No decision rows in `product-decision-register.md` change; the
register's change-history advances to 0.9.8.

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

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

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

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

Flutter
-------

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

Governance
----------

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

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

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

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

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

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

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

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

Hardening
---------

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

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

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

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

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

Live-verified on Moto G Stylus 5G against cn.teamspeak.app:
saved a bookmark, reconnected via it, joined a non-default
channel via tap, toggled both mutes, slid the volume, and the
redacted diagnostic export confirmed `AudioManager mode set to
MODE_IN_COMMUNICATION`, `client_move sent`, and `client_update
sent` lines.
2026-05-15 01:59:27 +08:00
EdisonJwa fd181c014c feat(audio): A.5 — surface mobile voice-preset + effects toggles in AudioEngineConfig
The Beta scope for mobile DSP is OS-source-driven (Android
`MediaRecorder.AudioSource.VOICE_COMMUNICATION`, iOS
`AVAudioSession.Mode.voiceChat`) — letting the platform's built-in
AEC / NS engage instead of shipping our own DSP chain on
constrained devices. Linux desktop stays a deliberate no-op:
PipeWire / ALSA's default source is correct for desktop voice and
adding a software AEC there would regress against an already-good
baseline.

This commit lands the *config surface* through every layer:

* `AudioEngineConfig` gains `effects: AudioEffects` (mirrors the
  DEC-007/008/009/010 toggles) and `mobile_voice_preset: bool`
  (default `true`).
* On Android, `AudioEngine::start` logs the preset + effects
  requests so a future cpal / Oboe upstream switch can be observed
  via the redacted diagnostic export.
* On iOS, the same log line documents the binding gap — Chanora
  iOS audio is documented-only for Beta per the release notes.
* On Linux desktop, the flags are honoured by name but the engine
  continues to use the default ALSA / PipeWire source. No
  behaviour change.

RISK-AUDIO-MOBILE-001 (new) tracks the actual preset switch. The
follow-up work either pulls in an Oboe-based input host or waits
for cpal upstream to expose `set_input_preset`. Either way the
config flag is forward-compatible — callers do not need to change
when the binding lands.
2026-05-15 01:28:23 +08:00
EdisonJwa 43a3c9ba76 feat(events): A.4 — emit SnapshotChanged from the watchdog probe
Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.

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

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

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

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

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

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

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

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

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

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

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

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

Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app:
killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three
misses, supervisor walked the backoff schedule, and the UI
reconnected automatically once the radios came back. Snapshot tree
re-rendered without user action.
2026-05-15 01:06:07 +08:00
EdisonJwa bc0da50cdb feat(protocol): A.1 — fix hostname resolution on Android and iOS
Resolves the Beta-blocking issue surfaced during Android v0.2.0-beta.1
verification: hostnames could not be used, only literal IPs.

Root cause:
  tsclientlib's built-in resolver uses hickory-resolver, which reads
  /etc/resolv.conf. That file does not exist on Android or iOS, so
  any connect by hostname exited the connection task before
  signalling ready and surfaced the cryptic error
    BridgeError.connection(field0: protocol backend:
      connection task exited before signalling ready)

Fix:
  crates/chanora_protocol/src/resolver.rs (new):
    Resolves hostnames via tokio::net::lookup_host, which uses the
    platform's getaddrinfo. Works on every platform Chanora targets.
    Tiny in-process positive-result cache (5 min TTL) keeps
    reconnects cheap. IPv4 sorted ahead of IPv6 in the returned list
    to favour the more reliable path on dual-stack networks.

  crates/chanora_protocol/src/adapter.rs:
    connection_task now resolves the hostname itself and passes the
    resulting SocketAddr (not the hostname String) to
    tsclientlib::Connection::build. tsclientlib's ServerAddress enum
    accepts SocketAddr via its From impl, so the upstream resolver
    is skipped entirely.

  crates/chanora_protocol/src/lib.rs:
    New typed error arm ProtocolError::DnsFailed { host, reason }
    so the UI can distinguish 'server not found' from 'server
    refused our packets'.

  crates/chanora_bridge/src/lib.rs:
    Matching BridgeError::DnsFailed { host, reason } DTO surfaced
    to Dart, with explicit From<CoreError::Protocol(DnsFailed)>
    mapping so the UI gets the structured fields rather than a
    stringified mess.

Tests added (crates/chanora_protocol/src/resolver.rs::tests):
  - rejects_empty
  - literal_ipv4_short_circuits
  - literal_ipv4_default_port_path
  - unresolvable_returns_dns_failed
  - resolves_known_hostname  (#[ignore], --ignored to run; hits net)

Empirical verification (2026-05-14):
  Workspace: cargo check + cargo test --workspace clean.
  Live resolver test: cn.teamspeak.app → 175.178.125.23:9987 (passes).
  cargo test -p chanora_core --test alpha_smoke -- --ignored:
    server='Vigorous Pro' channels=42 clients=20 (passes by hostname).
  flutter test: alpha_e2e_test + beta_e2e_test both green.
  Physical Moto G Stylus 5G (Android 14 arm64-v8a):
    APK rebuilt (48.9 MB). adb install + launch.
    Connect form left at default 'cn.teamspeak.app'.
    logcat shows:
      chanora_protocol: dns resolved input=cn.teamspeak.app
                                    resolved=175.178.125.23:9987
      tsclientlib: starting connection to 175.178.125.23:9987
      tsproto::resend: Connecting → Connected
      chanora_protocol: initial state snapshot received
    UI shows 'Connected to Vigorous Pro' / '42 channels • 20 online'.

This is the first item in Category A (post-Beta polish bundle).
Pause point: review before A.6 (full reconnect).
2026-05-15 00:17:13 +08:00
EdisonJwa 1324f478fe docs(release): add iOS build instructions + shell helper
The development host is Linux x86_64; the iOS toolchain (Xcode, xcrun,
codesign, iPhoneOS SDK) is macOS-only under Apple licence and cannot
be cross-compiled from Linux. This commit adds the instructions for
producing the iOS v0.2.0-beta.1 build on a macOS host plus a bash
helper that automates the build itself.

docs/release/ios-build.md (v0.1.0):
  - Toolchain pin table (macOS 14+, Xcode 26+ per DEC-021, iOS SDK
    26+, iOS deployment target 13.0 per DEC-003, Flutter 3.41.9,
    Rust 1.95 stable with aarch64-apple-ios / aarch64-apple-ios-sim /
    x86_64-apple-ios targets, CocoaPods 1.16+, FRB 2.12.0).
  - macOS host options: owned hardware vs rental (MacStadium,
    MacinCloud, Scaleway Apple silicon, AWS EC2 Mac) vs borrowed
    Mac. Realistic cost ranges per option.
  - Step-by-step Homebrew + Rust + Flutter + CocoaPods install.
  - Pre-built libopus.a per arch via a CMake invocation that
    targets the iOS SDK explicitly. Mirrors the Android build's
    LIBOPUS_LIB_DIR wrap-dir trick.
  - flutter create --platforms=ios to scaffold the ios/ folder
    (the product Flutter app was created with only linux + android).
  - Edits required to ios/Podfile and ios/Runner/Info.plist:
    iOS 13 deployment target (DEC-003), NSMicrophoneUsageDescription
    for the audio engine, UIBackgroundModes=audio for screen-locked
    playback.
  - Three cargo build --target invocations for device + both
    simulator slices.
  - lipo merge of the two simulator slices into one .a.
  - xcodebuild -create-xcframework to produce
    target/ChanoraBridge.xcframework with the right slices.
  - flutter build ios --release --no-codesign or
    flutter build ipa --release --export-method development for
    a signed .ipa.
  - Install paths: xcrun devicectl for wired install, altool for
    TestFlight upload.
  - Smoke-test instructions with the same hostname-resolution
    caveat that affects the Android Beta (hickory-resolver does
    not work on iOS; use the literal IP).
  - Packaging into chanora-v0.2.0-beta.1-ios.ipa.
  - Known-issue table covering: audiopus_sys cmake build failures
    on iOS, microphone permission prompt prerequisites,
    AVAudioSession category quirks for voice transmission, code-
    signing failure modes, TestFlight rejection causes.
  - Reproducibility note (build is not bit-reproducible).

tools/build-ios.sh:
  - Parameter switches: --version, --no-codesign,
    --regenerate-bindings, --export-method.
  - Verifies xcodebuild, xcrun, cargo, rustc, flutter, pod, lipo
    on PATH.
  - Adds the three rustup iOS targets if missing.
  - Verifies each pre-built libopus.a exists at the expected wrap
    dir before starting.
  - Optionally regenerates FRB bindings.
  - Three cargo build runs (device + Apple-silicon sim + Intel
    sim), each with LIBOPUS_LIB_DIR pointed at its arch's wrap dir
    and the audiopus_sys build-cache wiped per target.
  - lipo + xcodebuild -create-xcframework.
  - flutter pub get + pod install + flutter build {ios,ipa}.
  - Copies the .ipa to a versioned path under $HOME and prints
    SHA-256.

This is documentation + helper only; no actual iOS binaries are
produced by this commit. The Linux development host cannot run
Xcode. To produce the binaries, follow §3-§14 of
docs/release/ios-build.md on a macOS host, or run
tools/build-ios.sh there.

DEC-011.1 iOS audio status remains Deferred; the doc notes that
cpal's iOS backend has not been empirically verified and the
AVAudioSession category likely needs configuration for voice
transmission. Both are Beta+ items.
2026-05-14 23:49:58 +08:00
EdisonJwa c81ccfd9a9 feat(android): produce v0.2.0-beta.1 Android APK with voice in/out
Builds the Internal Beta product app for Android. Companion to the
Linux desktop build already shipped at the same tag.

What this commit adds to the source tree:

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

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

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

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

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

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

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

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

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

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

Honest limitation surfaced during verification:

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

Build prerequisites (documented for reproducibility):

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

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

Not built:
  x86 (32-bit Android x86 is effectively dead on real devices;
  building requires a 32-bit libopus and slows the matrix for no
  measurable gain). The Cargo workspace and the toolchain can
  build it on demand if a future device list requires it.
2026-05-14 23:40:58 +08:00
EdisonJwa 8094ec7277 docs(release): add Windows build instructions + PowerShell helper
The development host is Linux x86_64; `flutter build windows` cannot
be cross-compiled and requires a Windows host with Visual Studio
2022's C++ Desktop workload. This commit adds the instructions for
producing the Windows v0.2.0-beta.1 Internal Beta build on an Azure
VM, plus a PowerShell helper that automates the build itself.

docs/release/windows-build.md (v0.1.0):
  - Toolchain pin table (Windows Server 2022, VS 2022 Build Tools
    + C++ workload, Flutter 3.41.9, Rust 1.95 stable, FRB 2.12.0,
    CMake, audiopus build dependency).
  - Azure VM provisioning recipe: Standard_D4s_v5 (4 vCPU / 16 GiB),
    Premium SSD 128 GiB, RDP locked to caller IP, auto-shutdown,
    cost estimate (<USD 1 per build session).
  - Step-by-step PowerShell to install VS 2022 Build Tools with the
    required components, Git for Windows, rustup, Flutter SDK,
    flutter_rust_bridge_codegen, and CMake.
  - Two upload paths for source: temporary git remote OR zip archive
    over RDP clipboard.
  - flutter create --platforms=windows to scaffold the
    windows/ platform folder (the product Flutter app was created
    with only linux + android).
  - cargo build -p chanora_bridge to produce chanora_bridge.dll.
  - flutter build windows --release to produce chanora_flutter.exe
    and the bundle.
  - Drop the DLL next to the EXE so dart:ffi loads it.
  - Smoke-test instructions including the cn.teamspeak.app UDP 9987
    egress gotcha for some Azure regions.
  - Packaging into chanora-v0.2.0-beta.1-windows-x64.zip.
  - Known-issue / caveat table.
  - Reproducibility note (build is not bit-reproducible in this Beta).

tools/build-windows.ps1:
  - Parameter switches: -SkipRustBuild, -RegenerateBindings, -Version.
  - Verifies flutter, cargo, rustc, cmake, git on PATH.
  - Adds the x86_64-pc-windows-msvc target via rustup if missing.
  - Runs flutter create --platforms=windows if the windows/ folder
    is absent in apps/chanora_flutter/.
  - Optionally regenerates FRB bindings.
  - cargo build --release -p chanora_bridge --target x86_64-pc-windows-msvc.
  - flutter pub get + flutter build windows --release.
  - Copies the DLL into the Release bundle.
  - Compress-Archive into chanora-<version>-windows-x64.zip.
  - Prints final artefact paths.

This is documentation + helper only; no actual Windows binaries are
produced by this commit. To produce the binaries, follow §3-§13 of
docs/release/windows-build.md on a Windows host.
2026-05-14 23:02:56 +08:00
EdisonJwa 9790005c3e feat(beta): wire voice in/out end-to-end with push-to-talk (v0.2.0-beta.1)
Reaches the Internal Beta milestone of DEC-001's release sequence the
same day as Alpha. Adds voice capture and playback through the full
Flutter UI → FRB → Rust core → tsclientlib → server path.

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

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

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

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

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

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

Docs updates:
  - docs/governance/product-decision-register.md bumped to v0.9.7
    (Beta-milestone change-history entry; no row changes).
  - docs/governance/poc-results-summary.md bumped to v0.6.0
    (RISK-PoC-005 updated with Beta progress).
2026-05-14 22:43:57 +08:00
EdisonJwa 53b176b722 docs(governance): record Alpha milestone in PoC results summary (v0.5.0)
Updates RISK-PoC-005 to 'partially closed' and adds a v0.5.0 change
history entry documenting:
  - the tsclientlib spike promotion into crates/chanora_protocol;
  - the typed ChanoraSession in core/chanora_core wiring the
    protocol API;
  - the FRB 2.12.0 bridge wiring;
  - the Flutter Alpha UI;
  - the empirical verification path through alpha_e2e_test.dart
    and alpha_smoke.rs;
  - the v0.1.0-alpha.1 tag pointing at commit 3bb038c.

No other doc bumps are needed: the decision register stays at v0.9.6
(no new decisions), the audit reports stay at v0.9.3 (no new audit
evidence beyond what the PoCs already provided), the PoC plan stays
at v0.3.0 (all six PoC entries were already PASS).
2026-05-14 21:37:49 +08:00
598 changed files with 117499 additions and 34558 deletions
+49
View File
@@ -0,0 +1,49 @@
# audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot
# call cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults.
# Instead, point cmake-rs at a small wrapper that injects -D cache/policy
# variables during configure while passing cmake --build / --version / -E /
# --install / --open through unchanged. This keeps Opus Debug builds on
# Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT
# (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols
# at test link.
#
# IMPORTANT: Cargo's `[target.<triple>]` config sections only forward a
# fixed allowlist of keys (linker, runner, rustflags, rustdocflags, ar)
# to build scripts. Arbitrary keys such as `CMAKE` placed under
# `[target.<triple>]` are silently ignored and never reach the
# audiopus_sys build script. cmake-rs (via cc-style env resolution)
# looks up CMAKE in this order:
# 1. CMAKE_<target-triple-with-dashes>
# 2. CMAKE_<target_triple_with_underscores>
# 3. TARGET_CMAKE (or HOST_CMAKE when host == target)
# 4. CMAKE
# We therefore scope the wrapper to Windows MSVC targets by setting the
# target-suffixed variant in the global [env] section. Non-Windows
# hosts (macOS, Linux, iOS, Android) never see CMAKE set and invoke
# `cmake` directly.
#
# NOTE: CMAKE_POLICY_DEFAULT_CMP0091 and CMAKE_MSVC_RUNTIME_LIBRARY cannot
# be set via the process environment because CMake does NOT auto-import
# them into its cache; they must be passed as `-D` definitions, which the
# wrapper does.
#
# iOS deployment target (DEC-003: iOS 13.0 minimum) is NOT set here. It is
# enforced in two places that own the iOS build:
# 1. tools/build-ios.sh — sets IPHONEOS_DEPLOYMENT_TARGET for the cargo
# invocation and bypasses audiopus_sys's CMake build via
# LIBOPUS_STATIC=1 / LIBOPUS_NO_PKG=1 / LIBOPUS_LIB_DIR.
# 2. apps/chanora_flutter/ios/Runner.xcodeproj — sets the Xcode
# IPHONEOS_DEPLOYMENT_TARGET build setting for the final link.
# Setting it globally here would make native macOS `cargo check` runs try
# to link iPhone objects against the macOS SDK.
[env]
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
# Scope the cmake wrapper to Windows MSVC targets only via the
# target-suffixed env var name that cc/cmake-rs already resolve.
# Force = true so a developer's pre-existing CMAKE_x86_64-pc-windows-msvc
# does not silently bypass the wrapper. Relative = true so the path
# resolves from the workspace root regardless of where cargo is invoked.
CMAKE_x86_64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
CMAKE_aarch64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
+103
View File
@@ -0,0 +1,103 @@
name: bench-advisory
# SDD-120 §6 / SRS-218 — advisory-only realtime-audio bench workflow.
# Runs the criterion bench harness on PR + tag events, compares the
# current results against the SAD-089 baseline JSON resolved at the
# merge-base, and renders a markdown report posted (or updated) as a
# single sticky PR comment. The job status is ALWAYS success — this
# workflow never fails a check on regression (SRS-218 clause 4).
on:
pull_request:
types: [opened, synchronize, reopened]
push:
tags: ["**"]
permissions:
pull-requests: write
contents: read
jobs:
bench-advisory:
name: bench-advisory
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: System deps (cpal / Opus / SDL2)
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev libpulse-dev pkg-config \
libdbus-1-dev \
libsdl2-dev \
libopus-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run benchmarks
run: |
if ! cargo bench -p chanora_audio \
--bench realtime_capture \
--bench opus_codec \
--bench resampler; then
echo "::warning::Benchmark harness failed; continuing advisory workflow per SRS-218 clause 4"
fi
- name: Emit current baseline JSON
run: cargo run --example emit_baseline -p chanora_audio
- name: Resolve merge-base baseline
run: |
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
MERGE_BASE=$(git merge-base "$BASE_SHA" HEAD || true)
else
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD || true)
fi
if [ -n "$MERGE_BASE" ] && git show "${MERGE_BASE}:crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json" > baseline.json 2>/dev/null; then
echo "BASELINE_FOUND=1" >> "$GITHUB_ENV"
else
echo "BASELINE_FOUND=0" >> "$GITHUB_ENV"
echo "{}" > baseline.json
fi
- name: Compare against baseline
run: |
cargo run --example compare_baseline -p chanora_audio -- \
--current current.json \
--baseline baseline.json \
--output report.md
- name: Post PR comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('report.md', 'utf8');
const marker = '<!-- chanora-bench-advisory -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body && c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Upload report artifact (push events)
if: github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: bench-report
path: report.md
@@ -0,0 +1,53 @@
name: bench-baseline-update
# SDD-120 §7 / SAD-089 — manual-dispatch workflow that opens a PR
# updating the committed baseline JSON. This is the SOLE writer of
# `crates/chanora_audio/benches/baselines/<host>.json`. Triggered
# only on operator demand via workflow_dispatch.
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
bench-baseline-update:
name: bench-baseline-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: System deps (cpal / Opus)
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev libpulse-dev pkg-config \
libopus-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run benchmarks
run: |
cargo bench -p chanora_audio \
--bench realtime_capture \
--bench opus_codec \
--bench resampler
- name: Emit current baseline JSON
run: cargo run --example emit_baseline -p chanora_audio
- name: Copy current to baseline path
run: cp current.json crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
- name: Create pull request
uses: peter-evans/create-pull-request@v6
with:
branch: bench/baseline-update-${{ github.run_id }}
base: product/scaffold-v0
title: "chore(bench): update realtime audio baselines"
body: |
Automated baseline update produced by manual dispatch of
`bench-baseline-update.yml`.
Run: ${{ github.run_id }}
Triggered by: ${{ github.actor }}
commit-message: "chore(bench): update realtime audio baselines"
add-paths: |
crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
+138
View File
@@ -0,0 +1,138 @@
name: ci
on:
push:
tags: ["**"]
pull_request:
jobs:
rust:
name: cargo check + cargo test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: System deps (cpal / Opus / SQLite / SDL2)
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev libpulse-dev pkg-config \
libdbus-1-dev \
libsdl2-dev \
libopus-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo check --workspace
run: cargo check --workspace --locked
- name: cargo test --workspace
env:
# Storage tests must not hit the real OS keyring on CI:
# there is no D-Bus session available and the call would
# block. The runtime code carries the same toggle for
# headless / sandboxed environments.
CHANORA_DISABLE_KEYRING: "1"
run: cargo test --workspace --locked --no-fail-fast
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
continue-on-error: true
supply-chain:
name: cargo deny (licenses + advisories + bans + sources)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
# `licenses` enforces the DEC-020 license posture; the
# other three are minimal supply-chain hygiene per
# `docs/governance/legal-review-readiness.md` §5.
arguments: --workspace --all-features
license-inventory:
name: cargo about (license inventory)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-about
run: cargo install --locked --features cli cargo-about
- name: Regenerate inventory and compare
# Build the inventory in a temp file and diff against the
# committed copy. CI fails when the committed inventory is
# stale, forcing contributors to run the tool locally
# before opening a PR that touches the dependency tree.
run: |
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|| { echo "::error::docs/security/license-inventory.md is stale; regenerate with 'cargo about generate --output-file docs/security/license-inventory.md about-md.hbs'"; exit 1; }
flutter-license-inventory:
name: flutter license inventory
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- name: flutter pub get
working-directory: apps/chanora_flutter
run: flutter pub get
- name: Regenerate Flutter license inventory and compare
env:
# Resolved by the wrapper from $HOME/sdks/flutter when
# not set; CI's subosito/flutter-action puts flutter on
# PATH but exports the SDK root under FLUTTER_ROOT.
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
run: |
./tools/dump_flutter_licenses.sh
if ! git diff --quiet docs/security/flutter-license-inventory.md; then
echo "::error::docs/security/flutter-license-inventory.md is stale; regenerate with 'tools/dump_flutter_licenses.sh'"
git --no-pager diff docs/security/flutter-license-inventory.md | head -40
exit 1
fi
flutter:
name: flutter analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- name: flutter pub get
working-directory: apps/chanora_flutter
run: flutter pub get
- name: flutter analyze
working-directory: apps/chanora_flutter
run: flutter analyze
- name: flutter test (unit only)
working-directory: apps/chanora_flutter
run: flutter test --exclude-tags e2e
flutter-ios-release-build:
name: flutter iOS unsigned release build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- name: Try to fetch local SileroCoreML package
run: git submodule update --init --depth=1 silero-coreml || true
- name: flutter pub get
working-directory: apps/chanora_flutter
run: flutter pub get
- name: Check local SileroCoreML package
id: silero-coreml
run: |
if [ -f silero-coreml/Package.swift ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::Skipping iOS build because silero-coreml submodule is not available"
echo "available=false" >> "$GITHUB_OUTPUT"
fi
- name: flutter build ios --no-codesign
if: steps.silero-coreml.outputs.available == 'true'
working-directory: apps/chanora_flutter
run: flutter build ios --release --no-codesign
+33
View File
@@ -0,0 +1,33 @@
name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
with:
model: zhipuai-coding-plan/glm-5.1
+48
View File
@@ -43,6 +43,13 @@ android/app/build/
reports/ reports/
coverage/ coverage/
# SDD-120 local bench-harness outputs (cargo run --example emit_baseline /
# compare_baseline at the workspace root). Real baseline JSON is committed
# at crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
# via the bench-baseline-update.yml workflow only.
/current.json
/report.md
# Diagnostic bundles # Diagnostic bundles
diagnostics/ diagnostics/
*.diag.zip *.diag.zip
@@ -89,3 +96,44 @@ chanora-docs-repo-format-*.zip
# IntelliJ / Android Studio per-project # IntelliJ / Android Studio per-project
**/*.iml **/*.iml
# Eclipse / Buildship local project metadata
**/.project
**/.classpath
**/.settings/
# Flutter DevTools local preferences
**/devtools_options.yaml
# gen2/ holds the externally-provided review summary + zip baseline.
# Not part of the repo tree; do not commit.
/gen2/
# opencode agent local config
opencode.json
# Local protocol scratch file
/test_tsproto.rs
# iOS framework build artifacts produced by chanora_bridge.podspec
/apps/chanora_flutter/ios/Frameworks/
# macOS framework build artifacts produced by chanora_bridge.podspec
# (prepare_command + script_phase rm -rf and regenerate this tree on
# every pod install AND every Xcode build, so tracking it in git is
# pure waste — the committed binary was ~40 MB per commit).
/apps/chanora_flutter/macos/Frameworks/
.opencode/
.omo/
AGENTS.md
Screenshot 2026-05-17 at 22.23.07.png
# Xcode archive / export bundles (generated by Product > Archive > Distribute)
**/Chanora */
**/*.xcarchive/
**/*.ipa
**/*.dSYM/
# macOS release zip bundles produced by local release scripts
/chanora-v*.zip
+3
View File
@@ -0,0 +1,3 @@
[submodule "silero-coreml"]
path = silero-coreml
url = git@github.com:chanoraapp/silero-coreml.git
+120 -195
View File
@@ -2,212 +2,137 @@
All notable changes to Chanora will be documented in this file. All notable changes to Chanora will be documented in this file.
This project is expected to follow a Conventional Commits style workflow. This project follows a Conventional Commits style workflow.
## [Unreleased] ## [v0.3.0] — Cross-platform voice client baseline
### Added — Alpha build (v0.1.0-alpha.1) The v0.3.0 milestone transitions Chanora from an internal-beta voice
prototype to a cross-platform baseline client with event-driven UI,
visible per-client audio state, non-self client info parity, and
documented host Rust workspace plus Flutter validation gates. Android
target compile/install/smoke evidence remains blocked locally pending the
required NDK compiler and an authorized ADB target.
- **First Alpha build wires the connect → snapshot → disconnect cycle ### Added
end-to-end from the Flutter UI to a live TeamSpeak-compatible
server via the typed Flutter/Rust bridge.** Per DEC-001 this is the - **Event-driven UI** replaces timer-based snapshot polling. Protocol
Internal Alpha milestone; Audio (voice in/out) is deferred to Beta. deltas (client join/leave/move/update, channel add/remove/update)
- `crates/chanora_protocol/` promoted from a scaffold to a working flow through a typed `ProtocolDelta` enum and update the Flutter UI
adapter. Public surface: in real time. Channel switching is instant.
- `ConnectConfig`, `ProtocolClient`, `ProtocolError`. - **Per-client audio state visibility.** Client rows surface
- DTO module exposing `ChannelId`, `ClientId`, `ChannelInfo`, muted/deafened state in avatar badges. Per-user volume UI, persistence,
`ClientInfo`, `ServerSnapshot` — all owned primitives and and mixer wiring remain tracked as follow-up work.
`String`s; no `tsclientlib::*` types leak (SAD-067). - **Non-self client info parity with Qint.** The Info tab now populates
- Tokio task owns the `tsclientlib::Connection`; public handle connection metadata (name, description, created, last connected,
communicates via `mpsc` requests + `oneshot` replies. connections, transfer, ping deviation) for other clients via an
- Connect waits for the initial `BookEvents` snapshot, then pumps explicit `ClientProfileRefreshPlan` that fetches `connectioninfo`
events for ~2 s so the subscribed channel tree settles before and recent text messages with a bounded timeout.
the first snapshot is served. - **Ping deviation in client profiles.** `ping_deviation_milliseconds`
- Promoted from `poc/tsclientlib-connect-spike`. propagated from protocol DTO through bridge API to Dart, with a
- `core/chanora_core::ChanoraSession` now drives the protocol crate conditional l10n row in the client info sheet (en + zh).
with a typed `connect`/`snapshot`/`is_connected`/`disconnect` API. - **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
Enforces the DEC-006 single-connection invariant via an internal the private `silero-coreml` SwiftPM package is available. Product
`tokio::sync::Mutex<Option<ProtocolClient>>`. `VoiceActivity` remains reserved/disabled per DEC-030 until a later
- `crates/chanora_bridge/` wired against `flutter_rust_bridge` 2.12.0 baseline enables and verifies it.
(DEC-014). Compiled as `cdylib + staticlib + rlib`. Exposes: - **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
- `bridge_init()` (FRB lifecycle), `connect()`, `snapshot()`, lookups and `ts3server://` URI handling.
`disconnect()`, `is_connected()`. - **Per-ABI Android APK splitting.** `flutter build apk
- Typed `BridgeChannel`, `BridgeClient`, `BridgeSnapshot` DTOs; --split-per-abi` produces separate arm64-v8a / armeabi-v7a / x86_64
`BridgeError` with `From<chanora_core::CoreError>`. APKs instead of a single fat APK.
- A process-wide `tokio::Runtime` + `ChanoraSession` via - **Cross-platform CI** (GitHub Actions): cargo check + test, cargo
`OnceLock`, used by every async command. deny, cargo about license inventory, flutter analyze, flutter test,
- The crate's `#![forbid(unsafe_code)]` lint was lifted to flutter iOS unsigned release build. All gates must pass before merge.
`#![warn(missing_docs)]` only, with a doc-comment explanation - **Diagnostic recording.** Bridge events, audio interruptions, and
that the FRB-generated glue legitimately uses unsafe at the FFI Rust-level log entries are captured into an in-memory ring buffer
boundary; hand-written code in the crate is still expected to accessible from the diagnostics page.
avoid `unsafe`. - **Shared app snackbar styling** for consistent error/info feedback.
- `flutter_rust_bridge.yaml` at the repo root drives codegen for the
bridge.
- Generated Dart bindings under
`apps/chanora_flutter/lib/src/rust/{api.dart,frb_generated*.dart,lib*.dart}`.
- Generated Rust glue under `crates/chanora_bridge/src/frb_generated.rs`.
- `apps/chanora_flutter/lib/main.dart` rewritten as the Alpha UI:
- Form: server address + nickname, both pre-populated for
convenience.
- Connect button → calls FRB → enters connecting state → shows
snapshot.
- Snapshot view: server welcome banner (preserved verbatim per
ADR-008), `N channels • M online` count, ordered channel list
with clients indented under their channel.
- Refresh and Disconnect actions in the app bar.
- `apps/chanora_flutter/lib/l10n/app_{en,zh}.arb` expanded with the
Alpha key set:
`homeNotProductionReadyBanner` (now says "Alpha build"),
`fieldServerHost`, `fieldNickname`,
`connectAction`, `disconnectAction`, `refreshAction`,
`statusIdle`, `statusConnecting`, `statusConnected`, `statusError`,
`channelsHeading`, `clientsHeading`, `countChannelsAndClients`.
- `flutter_localizations`, `intl`, `flutter_rust_bridge`,
`freezed_annotation` added to dependencies;
`freezed` and `build_runner` added to dev_dependencies.
- `apps/chanora_flutter/test/alpha_e2e_test.dart` runs the full
Dart → FRB → Rust → tsclientlib → network → server path against
`cn.teamspeak.app`. Verifies the snapshot contains a non-empty
server name and a non-empty channel list, that `isConnected()`
flips true → false across the disconnect, and that a re-fetched
snapshot agrees on the server name. Passes in ~2.5 s.
- `core/chanora_core/tests/alpha_smoke.rs` runs the same path from the
Rust side; tagged `#[ignore]` so `cargo test --workspace` doesn't
hit the network by default. Run with `--ignored alpha_smoke`.
### Changed ### Changed
- `chanora_core::CoreError` no longer wraps `chanora_bridge::BridgeError`; - **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
the relationship is the other way around (bridge maps from core). serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
This removes a cyclic `chanora_core``chanora_bridge` dependency audio startup.
introduced when the bridge crate gained `chanora_core` as a dep. - **Linux native audio path promoted** with ONNX Runtime VAD assets
- `chanora_bridge` lints relaxed from `#![forbid(unsafe_code)]` to bundled for future `VoiceActivity` work. Desktop voice I/O works on
`#![warn(missing_docs)]` (documented above). PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
### LICENSE files (carry-over from earlier in this branch) startup permission flow, and system back-button integration.
- **`SnapshotChanged` event removed.** Replaced by the typed delta
- `LICENSE-APACHE` — Apache License Version 2.0 text (DEC-020). stream. The dead variant was removed end-to-end (protocol, bridge,
- `LICENSE-MIT` — MIT License text (DEC-020). Flutter).
- Initial repository foundation files. - **Prefetch crate renamed** from the PoC-era name to
- Documentation-first project structure. `chanora_prefetch`. All docs, specs, and code updated.
- `justfile` with `format`, `lint`, `test`, `verify-docs`, and - **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
`security-scan` targets, completing `repository-bootstrap-plan` v0.1.0 §3. packages remain versioned separately at `0.2.0-beta.1`.
- `poc/tsclientlib-connect-spike/` — PoC proving protocol feasibility via - **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
`tsclientlib`. Verified against `cn.teamspeak.app` on 2026-05-13.
- `poc/flutter_rust_bridge_hello/` — PoC proving the Flutter↔Rust command
and event-stream boundary via `flutter_rust_bridge` 2.12.0. Verified on
Linux desktop on 2026-05-13.
- `poc/secure-storage-spike/` — PoC proving platform secure storage via
a typed `SecretStorageRepository` trait and a Linux adapter selecting
between Secret Service (libsecret) and kernel keyutils. Audit checks
SS-AUD-001/002/003/005/006 and SS-TC-003 verified on 2026-05-13.
- `poc/sqlite-storage-spike/` — PoC proving SRS-089's "embedded data
store + migration mechanism" acceptance criteria: forward-only schema
migrator tracked via `PRAGMA user_version`, repository pattern with
`BookmarkRepository` / `SettingsRepository` traits over
`LocalDatabaseRepository`. 11/11 tests verified on 2026-05-13.
- `poc/diagnostics-redaction-spike/` — PoC proving the diagnostic
redaction policy from `diagnostic-redaction-audit-report.md`:
typed policy + regex rules + literal known-secret registry + bundle
redaction. Audit cases REDACT-TC-001..010 verified on 2026-05-13.
- `poc/audio-capture-playback-spike/` — PoC proving platform audio
capture/playback via cpal. Desktop half (Linux + PipeWire)
empirically verified end-to-end on 2026-05-13; mobile half closed
separately by `poc/audio-capture-playback-android-spike`.
- `poc/audio-capture-playback-android-spike/` — PoC closing the
mobile half of the audio capture/playback PoC plan entry. Rust
cdylib + JNI + Kotlin Android app; cpal targets Android's Oboe
backend (AAudio). Verified end-to-end on a physical Motorola
Moto G Stylus 5G (2023) running Android 14 arm64-v8a on
2026-05-13: 500 ms 440 Hz sine wave driven out the device speaker
(22,050 frames at 44.1 kHz) and 1 s captured from the microphone
into a valid 85,292-byte RIFF/WAVE mono 16-bit PCM file pulled via
`adb exec-out run-as`.
- `poc/README.md` summarising PoC status against
`docs/architecture/proof-of-concept-plan.md`.
### Changed
- **DEC-020 license resolved.** Chanora is now dual-licensed under
**Apache-2.0 OR MIT** (recipient's choice), the standard
Rust-ecosystem permissive model. Compatible with every direct
dependency (`tsclientlib`, `flutter_rust_bridge`, `cpal`, `rusqlite`,
`keyring`, etc.) and with the Flutter framework's BSD-3-Clause.
`LICENSE` rewritten as a dual-license aggregator pointing at
`LICENSE-APACHE` and `LICENSE-MIT`. `NOTICE` rewritten with current
direct-dependency attributions. `README.md` §License updated.
- `docs/governance/product-decision-register.md` bumped to v0.9.6:
DEC-020 status promoted from Open to Accepted. §4 license row
updated. §6 collapsed: there is no longer any open decision —
DEC-012 legal review remains as a *work* item, not a pending
decision. Change-history entry added.
- `docs/governance/poc-results-summary.md` bumped to v0.4.0:
RISK-PoC-003 closed. DEC-020 row moved from the "Still open"
section into the closed table.
- `docs/governance/product-decision-register.md` bumped to v0.9.5:
owner confirmation on all 17 previously-Proposed decisions
(DEC-001..010, 012, 015..019, 021). Sixteen were Accepted as
recommended; two were modified by the owner — **DEC-004**
Android minimum raised from API 24 to **API 28**, and
**DEC-015** product language for MVP expanded from English-only
to **English + Chinese (Simplified)**. DEC-020 license remains
Open / Deferred and is now the only public-release-gating
decision outstanding. §4 renamed "Recommended" → "Accepted MVP
Defaults" with MODIFIED rows annotated. §6 collapsed to the
single remaining DEC-020 item. §7 dated and statused for every
decision.
- `docs/governance/poc-results-summary.md` bumped to v0.3.0:
RISK-PoC-004 closed by the owner-confirmation pass; new
RISK-PoC-006 (Android `minSdk` move 24 → 28 for product code)
and RISK-PoC-007 (MVP language expansion to en + zh-Hans) added.
- `docs/architecture/proof-of-concept-plan.md` bumped to v0.3.0 to
promote the audio PoC from PARTIAL PASS to PASS after the Android
mobile half was closed; all six PoC plan entries are now PASS.
- `docs/governance/product-decision-register.md` bumped to v0.9.4 to
promote DEC-011.1 mobile half from Deferred to Accepted (Android),
keeping iOS Deferred.
- `docs/governance/poc-results-summary.md` bumped to v0.2.0:
audio row promoted to PASS, RISK-PoC-001 narrowed from "mobile
audio" to "iOS audio only", Android toolchain added to the
toolchain table.
- `poc/audio-capture-playback-spike/VERIFICATION.md` updated to point
at the Android spike for the mobile half.
- `poc/README.md` updated to list both audio spike directories.
- `docs/architecture/proof-of-concept-plan.md` bumped to v0.2.0 to
record PoC outcomes (5 PASS, 1 PARTIAL) and add a Status column.
- `docs/security/secure-storage-audit-report.md` bumped to v0.9.3:
SS-AUD-001/002/003/005/006 status set to PoC Pass with evidence
pointers; SS-TC-003 (Linux) Actual Result populated and Status set
to PoC Pass; findings SS-FIND-001..003 added; non-Linux test cases
marked Deferred.
- `docs/security/diagnostic-redaction-audit-report.md` bumped to
v0.9.3: REDACT-TC-001..010 status set to PoC Pass with evidence
pointers; export bundle policy §5 populated; findings
REDACT-FIND-001..003 added.
- `docs/governance/product-decision-register.md` bumped to v0.9.3:
owner-confirmed decisions recorded — DEC-014 Accepted
(`flutter_rust_bridge` 2.x pinned), DEC-013.1 Accepted (`rusqlite`
bundled), DEC-013.2 Accepted (Linux Secret Service preferred,
keyutils fallback), DEC-011.1 Accepted (desktop `cpal`) / Deferred
(mobile), DEC-022 Accepted (canonical implementation directory
layout per README sketch + SAD §7.2), DEC-020 explicitly Deferred
and remains a public-release blocker.
### Added (governance)
- `docs/governance/poc-results-summary.md` v0.1.0 — single-page
reviewer-facing summary of the PoC phase, the toolchain exercised,
the owner decisions taken, the audit coverage, and the open risks
RISK-PoC-001..005.
### Fixed ### Fixed
- N/A - **`clamp` panic on Info tab open.** `adapter.rs` cast `i64::MIN` to
`u128` before clamping, producing `min > max`. Replaced with
`.min(i64::MAX as u128) as i64`.
- **Non-self client profiles empty.** Added `request_client_db_info()`
with graceful fallback for missing fields.
- **Server query clients visible in UI.** Delta joins now filter
`is_server_query` clients from the channel tree.
- **Speaking indicators lost after reconnect.** Event forwarders are
reattached after the connection task restarts.
- **Channel double-tap join.** 1-second cooldown prevents rapid
re-join attempts.
- **`getconnectioninfo` errors silently discarded.** Now logged at
warning level instead.
- **iOS keyboard lag.** Multiple focus/tap handling fixes for iOS 26
`TextField` stale-focus regression.
- **Storage test isolation.** Temp directories no longer collide across
parallel test runs.
- **CI submodule fetch.** iOS build job uses best-effort `git submodule
update` with `|| true` fallback for the private `silero-coreml`
package.
- **Stale license inventory.** Regenerated `docs/security/license-
inventory.md` to match current `Cargo.lock`.
### Security ### Notes
- N/A - The `silero-coreml` submodule references a private repository
(`chanoraapp/silero-coreml`). CI builds skip the iOS unsigned build
when the submodule is unavailable.
- Default base branch is `main` (previously `product/scaffold-v0`).
- DSP chain (AEC / AGC / NS / HPF) is not yet production-tuned.
`AudioEffects` struct exists but filters are placeholder-grade.
- Desktop push-to-talk is capability-based; global PTT requires
OS-specific permissions and is not yet wired end-to-end in the UI.
## [v0.2.0-beta.1] — Internal Beta first build
- **Voice in/out wired end-to-end through the Flutter UI.** Per DEC-001
this reaches the Internal Beta milestone.
- `crates/chanora_audio/` promoted from scaffold to working engine:
cpal capture (mic gain, linear resampling to 48 kHz, mono down-mix)
and playback (48 kHz stereo). Opus VoIP encoding (20 ms frames).
Push-to-talk gate. Live counters.
- `crates/chanora_protocol/` extended with voice channels.
- `core/chanora_core::ChanoraSession` audio API.
- `crates/chanora_bridge/` audio surface for Dart.
- Beta UI rewrite with connect, PTT, and audio stats.
### Notes
- DSP chain not yet implemented (AEC / AGC / NS / HPF deferred).
- Capture resampler is linear interpolation (production resampler
deferred).
- Identity is ephemeral per connect; persistence deferred.
- No live event stream — UI polls on timer.
- `chanora_diagnostics` still scaffold.
- Audio engine is desktop-only in this build.
## [v0.1.0-alpha.1] — Internal Alpha
- First Alpha build wires connect → snapshot → disconnect end-to-end
from the Flutter UI to a live TeamSpeak-compatible server via the
typed Flutter/Rust bridge.
- `crates/chanora_protocol/` promoted from scaffold to working adapter.
- `crates/chanora_bridge/` wired against `flutter_rust_bridge` 2.12.0.
- Alpha UI: server address form, connect button, snapshot view.
## Versioning note ## Versioning note
Generated
+2283 -117
View File
File diff suppressed because it is too large Load Diff
+50 -14
View File
@@ -7,19 +7,21 @@
# apps/chanora_flutter/ — Flutter application (separate toolchain) # apps/chanora_flutter/ — Flutter application (separate toolchain)
# core/chanora_core/ — top-level Rust API + orchestration # core/chanora_core/ — top-level Rust API + orchestration
# crates/chanora_protocol/ — tsclientlib isolation # crates/chanora_protocol/ — tsclientlib isolation
# crates/chanora_resolver/ — TeamSpeak address resolution
# crates/chanora_state/ — snapshot, deltas, reducers # crates/chanora_state/ — snapshot, deltas, reducers
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer # crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
# crates/chanora_cache/ — avatar/icon blob cache (cacache-backed)
# crates/chanora_storage/ — bookmarks, settings, identity refs # crates/chanora_storage/ — bookmarks, settings, identity refs
# crates/chanora_diagnostics/ — logs, redaction, export # crates/chanora_diagnostics/ — logs, redaction, export
# crates/chanora_prefetch — server-resolution prefetch cache/policy
# crates/chanora_bridge/ — Flutter/Rust typed DTOs + glue # crates/chanora_bridge/ — Flutter/Rust typed DTOs + glue
# #
# The Flutter app is not part of this Cargo workspace; it is owned by # The Flutter app is not part of this Cargo workspace; it is owned by
# Gradle / Xcode / Flutter tooling under apps/chanora_flutter/. # Gradle / Xcode / Flutter tooling under apps/chanora_flutter/.
# #
# Non-promotion reminder (proof-of-concept-plan.md §4): nothing under # Proof-of-concept spikes are archived separately in the private
# poc/ is product code. The crates here are blank scaffolds at this # chanoraapp/chanora-poc repository. Promotion of PoC findings into
# stage; promotion of PoC code happens crate-by-crate with explicit # product code happens crate-by-crate with explicit audit-trail commits.
# audit-trail commits.
[workspace] [workspace]
resolver = "2" resolver = "2"
@@ -29,25 +31,19 @@ members = [
"crates/chanora_state", "crates/chanora_state",
"crates/chanora_audio", "crates/chanora_audio",
"crates/chanora_storage", "crates/chanora_storage",
"crates/chanora_cache",
"crates/chanora_diagnostics", "crates/chanora_diagnostics",
"crates/chanora_prefetch",
"crates/chanora_bridge", "crates/chanora_bridge",
"crates/chanora_resolver",
] ]
# Excludes: the PoC trees stay outside the product workspace so that
# `cargo check --workspace` at the root does not pull in PoC builds.
exclude = [ exclude = [
"poc/tsclientlib-connect-spike",
"poc/secure-storage-spike",
"poc/sqlite-storage-spike",
"poc/diagnostics-redaction-spike",
"poc/audio-capture-playback-spike",
"poc/audio-capture-playback-android-spike",
"poc/flutter_rust_bridge_hello",
"apps/chanora_flutter", "apps/chanora_flutter",
] ]
[workspace.package] [workspace.package]
version = "0.0.1-pre" version = "0.2.0-beta.1"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
authors = ["The Chanora Project Contributors"] authors = ["The Chanora Project Contributors"]
@@ -62,3 +58,43 @@ readme = "README.md"
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
# DEC-032 exit step: override the `cmake` crate with the fork carrying
# cmake-rs PR #257 (https://github.com/rust-lang/cmake-rs/pull/257).
# The patch forwards `ANDROID_ABI` and `ANDROID_PLATFORM` environment
# variables (set per-invocation by cargo-ndk >= 4.x and reinforced by
# our SDD-118 item 5 cleanEnv map) to the child `cmake` invocation as
# `-D` variables, so audiopus_sys's libopus CMake build picks up the
# correct per-ABI target on multi-ABI Android builds. Without it the
# child cmake falls through to the NDK toolchain file's default and
# armeabi-v7a / x86_64 fail to compile.
#
# Pinned by exact commit SHA (the `android-build` branch tip on
# pr2502/cmake-rs as of 2026-05-18) so the patch is reproducible.
# Re-evaluate and remove once PR #257 merges and a fresh `cmake`
# release lands on crates.io (current upstream is 0.7.2).
# Patch tsproto-types to left-pad P-256 coordinates that are shorter than
# 32 bytes. BigInt::to_bytes_be() strips leading zero bytes; when a
# coordinate happens to start with 0x00 (~0.8 % probability per coordinate)
# the upstream code rejects it with WrongPublicKeyLength, breaking the
# init-server handshake. The fix zero-pads to the field size instead.
# Fork: https://github.com/EdisonJwa/tsclientlib/tree/fix/p256-short-coordinate-pad
[patch."https://github.com/ReSpeak/tsclientlib.git"]
tsproto-types = { git = "https://github.com/EdisonJwa/tsclientlib.git", branch = "fix/p256-short-coordinate-pad" }
[patch.crates-io]
cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" }
# Apple-only DWARF emission for archive validation lives in the iOS and
# macOS chanora_bridge podspecs (apps/chanora_flutter/{ios,macos}/
# chanora_bridge.podspec) as per-build environment overrides:
#
# CARGO_PROFILE_RELEASE_DEBUG=true
# CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off
# CARGO_PROFILE_RELEASE_STRIP=false
#
# This keeps Android, Linux, and Windows release binaries on the cargo
# default release profile (no DWARF, no extra ~10MB symbol payload).
# Apple builds need the DWARF so dsymutil can emit a usable
# chanora_bridge.framework.dSYM that the archive validator accepts.
-27
View File
@@ -1,27 +0,0 @@
Chanora is dual-licensed under either of:
* Apache License, Version 2.0
([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0)
* MIT license
([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT)
at your option.
This dual-license model is recorded in
`docs/governance/product-decision-register.md` (decision DEC-020,
Accepted on 2026-05-14).
## Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in Chanora by you, as defined in the
Apache-2.0 license, shall be dual-licensed as above, without any
additional terms or conditions.
## Third-party software
Chanora bundles or links to third-party software whose own licenses
apply. See `NOTICE` for an inventory and the per-dependency license
texts that ship with the released artefacts. The legal review of the
full dependency set is tracked by DEC-012 and must complete before any
public/store release.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a
fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your
own behalf and on Your sole responsibility, not on behalf of any
other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by,
or claims asserted against, such Contributor by reason of your
accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 The Chanora Project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 The Chanora Project Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+50 -43
View File
@@ -23,58 +23,65 @@ tracked by DEC-012 in
docs/governance/product-decision-register.md and must complete before docs/governance/product-decision-register.md and must complete before
any public/store release. any public/store release.
Direct Rust dependencies of the proof-of-concept code (current as of Direct Rust dependencies of the MVP product code (as of v1.0.0-rc.1):
2026-05-14):
* tsclientlib — MIT OR Apache-2.0 Protocol + audio
* tsclientlib — MIT OR Apache-2.0
https://github.com/ReSpeak/tsclientlib https://github.com/ReSpeak/tsclientlib
* flutter_rust_bridge — MIT * tsproto / tsproto-packets — MIT OR Apache-2.0 (workspace of tsclientlib)
https://github.com/fzyzcjy/flutter_rust_bridge * cpal — Apache-2.0
* cpal — Apache-2.0
https://github.com/RustAudio/cpal https://github.com/RustAudio/cpal
* rusqlite — MIT * audiopus — MIT OR Apache-2.0
Bridge + framework glue
* flutter_rust_bridge — MIT
https://github.com/fzyzcjy/flutter_rust_bridge
* tokio — MIT
* futures — MIT OR Apache-2.0
* thiserror — MIT OR Apache-2.0
* tracing / tracing-subscriber / tracing-android — MIT
* serde — MIT OR Apache-2.0
Storage + secure storage
* rusqlite (bundled) — MIT
https://github.com/rusqlite/rusqlite https://github.com/rusqlite/rusqlite
* keyring — MIT OR Apache-2.0 * libsqlite3-sys — MIT
* chacha20poly1305 — Apache-2.0 OR MIT
* rand — MIT OR Apache-2.0
* zeroize — MIT OR Apache-2.0
* base64 — MIT OR Apache-2.0
* keyring — MIT OR Apache-2.0
https://github.com/hwchen/keyring-rs https://github.com/hwchen/keyring-rs
* linux-keyutils — BSD-3-Clause
* hound — Apache-2.0
* regex — MIT OR Apache-2.0
* serde / serde_json — MIT OR Apache-2.0
* tokio — MIT
* tracing / tracing-subscriber — MIT
* jni — MIT OR Apache-2.0
* ndk-context — MIT OR Apache-2.0
* android_logger — MIT OR Apache-2.0
* thiserror — MIT OR Apache-2.0
* zeroize — MIT OR Apache-2.0
* indoc — MIT OR Apache-2.0
* tempfile — MIT OR Apache-2.0
* once_cell — MIT OR Apache-2.0
* anyhow — MIT OR Apache-2.0
* clap — MIT OR Apache-2.0
* futures — MIT OR Apache-2.0
* serial_test — MIT
Direct Flutter / Dart dependencies of the FRB hello PoC: Android JNI
* jni — MIT OR Apache-2.0
* ndk-context — MIT OR Apache-2.0
* Flutter framework — BSD-3-Clause Direct Flutter / Dart dependencies of the MVP product code:
* flutter_rust_bridge — MIT (Dart side mirrors the Rust side)
Direct Android dependencies of the Android audio spike: * Flutter framework — BSD-3-Clause
* flutter_rust_bridge — MIT (Dart side mirrors the Rust side)
* connectivity_plus — BSD-3-Clause
* path_provider — BSD-3-Clause
* intl — BSD-3-Clause
* cupertino_icons — MIT
* haptic_kit — MIT
https://github.com/erykkruk/flutter_vibration_animation
* freezed_annotation — MIT
* flutter_lints (dev) — BSD-3-Clause
* build_runner (dev) — BSD-3-Clause
Direct Android-app dependencies (apps/chanora_flutter/android/):
* androidx.core:core-ktx, androidx.appcompat:appcompat — Apache-2.0 * androidx.core:core-ktx, androidx.appcompat:appcompat — Apache-2.0
* Android NDK r26.x runtime libraries — Apache-2.0 / per-component licences * Android NDK r26.x runtime libraries — Apache-2.0 / per-component licences
* Kotlin stdlib — Apache-2.0 * Kotlin stdlib — Apache-2.0
* Gradle wrapper — Apache-2.0 * Gradle wrapper — Apache-2.0
This list reflects PoC code only. The product-code dependency set Transitive dependencies are not enumerated here. The full machine-
(`apps/chanora_flutter/`, `crates/chanora_*`) is not yet established; generated license inventory for a release build can be produced with
its full license inventory will be re-collected and reviewed under `cargo about generate` (Rust) and Flutter's `LicenseRegistry` (Dart);
DEC-012 before public release. that output must be shipped with each released artefact. See
docs/security/dependency-and-supply-chain-report.md for the supply-
Transitive dependencies are not enumerated here. A complete chain audit record and docs/governance/legal-review-readiness.md for
machine-generated inventory must be produced by the build tooling the DEC-012 sign-off checklist.
(e.g. `cargo about` for Rust and the Flutter LicenseRegistry for Dart)
and shipped with released artefacts. See
docs/security/dependency-and-supply-chain-report.md for the audit
record.
+60 -11
View File
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
## Status ## Status
Chanora is currently in early planning and baseline-candidate design. Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
```text ```text
Current documentation baseline: v0.9.2 Current documentation baseline: v0.9.x document set
Current status: Baseline Candidate Current status: Baseline Candidate
Implementation status: Not production-ready Implementation status: Not production-ready
``` ```
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
The current engineering focus is: The current engineering focus is:
- defining the system and software architecture; - defining the system and software architecture;
- preparing the Flutter + Rust application structure; - hardening the Flutter + Rust application structure;
- validating TeamSpeak-compatible protocol integration through `tsclientlib`; - validating TeamSpeak-compatible protocol integration through `tsclientlib`;
- defining cross-platform audio behavior; - defining cross-platform audio behavior;
- preparing release, verification, security, privacy, and legal gates. - preparing release, verification, security, privacy, and legal gates.
@@ -46,13 +46,24 @@ Current platform policy:
| Platform | Baseline | | Platform | Baseline |
|---|---| |---|---|
| iOS / iPadOS runtime target | iOS 13+ unless Flutter, plugin, audio, or product constraints require raising it | | iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 | | App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it | | Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
| Google Play target API | Target the Google Play-required API level on upload date | | Google Play target API | Target the Google Play-required API level on upload date |
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements. The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
Apple CoreML VAD development requires the private `silero-coreml` SwiftPM package checked out as a sibling of this repository, so the app checkout and package checkout share the same parent directory:
```text
workspace/
chanora/
silero-coreml/
```
The iOS and macOS Xcode projects reference that package via `../../../../silero-coreml` from their project files. GitHub CI skips the unsigned iOS build when the sibling package is unavailable, but local Apple builds need that checkout.
--- ---
## Architecture Overview ## Architecture Overview
@@ -113,6 +124,46 @@ The current recommended MVP scope is:
--- ---
## Desktop Push-to-Talk
Chanora's desktop Push-to-Talk (PTT) follows a **capability-based** design (see
[`docs/architecture/desktop-ptt-architecture.md`](docs/architecture/desktop-ptt-architecture.md)).
Focused PTT — the user holds a bound key or mouse button inside the
focused Chanora window — is mandatory on Windows, macOS, and Linux.
Global PTT (recognised while the application is not focused) is
**capability-dependent**: it requires the operating system, the
user-granted permission set, the display server, and the available
input backend to all permit it.
The application reports a `PttCapabilityLevel` (`L0Focused`,
`L1GlobalShortcut`, `L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`)
that matches actual runtime behaviour, not the platform's theoretical
maximum. The UI capability badge shows the live value.
Per-platform strategy (resolved by owner rulings 2026-05-15, see
`docs/governance/product-decision-register.md` DEC-023 through
DEC-028):
* **Windows** — Raw Input first, low-level keyboard hook fallback,
Focused PTT terminal fallback. Mouse side buttons supported.
P0 / MVP.
* **macOS** — permission-aware Event Tap with Focused PTT fallback;
Global PTT upgrades asynchronously when the user grants Input
Monitoring / Accessibility. P0 / MVP.
* **Linux** — officially tested on **GNOME on Wayland** using the
`org.freedesktop.portal.GlobalShortcuts` interface; every other
Linux environment falls back to Focused PTT. Release notes do
not claim Global PTT support outside the tested compositor.
* Raw key codes, scan codes, virtual-key values, keysyms, and
key-press timing sequences are never logged or included in the
user-initiated diagnostic export. The diagnostic export carries
only capability level, backend identifier, and bound input
class.
A missed-key-up watchdog (default 30 s) clears `transmit_active`
when the OS suppresses a key-up event so a stuck-PTT bug class is
ruled out by construction.
## Repository Layout ## Repository Layout
The repository documentation is expected to live under `docs/`. The repository documentation is expected to live under `docs/`.
@@ -179,7 +230,7 @@ docs/
aspice-swe2-swe3-integration-note.md aspice-swe2-swe3-integration-note.md
``` ```
Implementation source folders may be added later. A likely structure is: Implementation source folders are present in this workspace. The current high-level structure is:
```text ```text
apps/ apps/
@@ -197,7 +248,7 @@ crates/
chanora_bridge/ chanora_bridge/
``` ```
The exact implementation layout should be finalized when the repository scaffold is created. The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
--- ---
@@ -344,9 +395,7 @@ docs/governance/git-commit-message-convention.md
## Development ## Development
Implementation commands will be added after the repository scaffold is finalized. Common local commands include:
Expected future commands may include:
```bash ```bash
flutter pub get flutter pub get
@@ -356,7 +405,7 @@ cargo clippy
cargo fmt cargo fmt
``` ```
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created. Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
--- ---
+54
View File
@@ -0,0 +1,54 @@
# Chanora — Third-party license inventory
This document enumerates every third-party crate that ships in a
release build of Chanora and the license under which Chanora
redistributes it. Generated by `cargo about generate` from
`about.toml` at the repository root.
Chanora itself is dual-licensed under
[Apache License 2.0](../../LICENSE-APACHE) or the
[MIT License](../../LICENSE-MIT) at the recipient's option (DEC-020
in `docs/governance/product-decision-register.md`). The crates
listed below carry their own licenses and are redistributed under
those terms.
## Licenses in use
| License | Crate count |
|---------|-------------|
{{#each overview}}
| `{{name}}` | {{count}} |
{{/each}}
## Crates
| Crate | Version | License | Source |
|-------|---------|---------|--------|
{{#each licenses}}
{{#each used_by}}
| {{crate.name}} | {{crate.version}} | `{{../name}}` | {{#if crate.repository}}<{{crate.repository}}>{{else}}{{/if}} |
{{/each}}
{{/each}}
## Full license texts
{{#each licenses}}
### {{name}}
```
{{text}}
```
{{/each}}
---
Regenerate with:
```bash
cargo about generate --output-file docs/security/license-inventory.md about-md.hbs
cargo about generate --output-file docs/security/license-inventory.html about.hbs
```
This artefact supports the DEC-012 legal review handoff at
`docs/governance/legal-review-readiness.md`.
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chanora — Third-party license inventory</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 80ch; margin: 2em auto; padding: 0 1em; line-height: 1.5; }
h1, h2 { border-bottom: 1px solid #ccc; padding-bottom: 0.2em; }
.crate { margin-bottom: 1.5em; padding: 0.6em 1em;
border: 1px solid #ddd; border-radius: 4px; background: #fafafa; }
.crate h3 { margin: 0 0 0.4em 0; font-size: 1em; }
.license { font-family: ui-monospace, SFMono-Regular, monospace;
background: #eef; padding: 0.1em 0.4em; border-radius: 3px; }
pre { background: #f0f0f0; padding: 1em; overflow-x: auto;
max-height: 30em; font-size: 0.85em; }
.footer { margin-top: 3em; padding-top: 1em; border-top: 1px solid #ccc;
color: #666; font-size: 0.85em; }
</style>
</head>
<body>
<h1>Chanora — Third-party license inventory</h1>
<p>
This page enumerates every third-party crate that ships in a release
build of Chanora and the license under which Chanora redistributes
it. Generated by <code>cargo about generate</code> from
<code>about.toml</code> at the repository root; regenerate via the
<code>just license-inventory</code> recipe.
</p>
<p>
Chanora itself is dual-licensed under
<a href="../../LICENSE-APACHE">Apache License 2.0</a> or the
<a href="../../LICENSE-MIT">MIT License</a> at the recipient's option
(see DEC-020 in
<code>docs/governance/product-decision-register.md</code>).
The crates listed below carry their own licenses and are
redistributed under those terms.
</p>
<h2>Licenses in use</h2>
<ul>
{{#each overview}}
<li><span class="license">{{name}}</span> — used by {{count}} crate(s).</li>
{{/each}}
</ul>
<h2>Crates</h2>
{{#each licenses}}
{{#each used_by}}
<div class="crate">
<h3>{{crate.name}} {{crate.version}}</h3>
<p>License: <span class="license">{{../name}}</span></p>
{{#if crate.repository}}<p>Source: <a href="{{crate.repository}}">{{crate.repository}}</a></p>{{/if}}
</div>
{{/each}}
{{/each}}
<h2>Full license texts</h2>
{{#each licenses}}
<h3>{{name}}</h3>
<pre>{{text}}</pre>
{{/each}}
<div class="footer">
Regenerate with <code>cargo about generate --output-file docs/security/license-inventory.html about.hbs</code>.
This artefact supports the DEC-012 legal review handoff at
<code>docs/governance/legal-review-readiness.md</code>.
</div>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
# cargo-about configuration for the Chanora workspace.
#
# Lists the SPDX licenses the workspace is permitted to depend on
# transitively. Each entry corresponds to a license that has been
# audited and accepted under DEC-020 (`docs/governance/
# product-decision-register.md`).
#
# Generate the human-readable inventory:
#
# cargo about generate \
# --output-file docs/security/license-inventory.html \
# about.hbs
# cargo about generate \
# --output-file docs/security/license-inventory.md \
# about-md.hbs
#
# The generated artefacts are checked into `docs/security/` and
# referenced from `docs/governance/legal-review-readiness.md` §5.
accepted = [
"Apache-2.0",
"MIT",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"0BSD",
"BSL-1.0",
"MPL-2.0",
"Unicode-DFS-2016",
"Unicode-3.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
"OpenSSL",
]
# Targets we ship binaries for. Includes the three desktop targets,
# Android, and iOS so that platform-specific crates surface even on
# a Linux build host.
targets = [
"x86_64-unknown-linux-gnu",
"aarch64-linux-android",
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"aarch64-apple-ios",
]
# Build-script-only crates should not influence the redistributed
# license inventory.
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
# Per-crate license clarifications. Anchored on a manual read of
# the upstream LICENSE file when the crate ships a `license-file`
# entry rather than an SPDX `license` expression.
[allo-isolate.clarify]
license = "Apache-2.0"
[[allo-isolate.clarify.git]]
path = "LICENSE"
checksum = "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4"
+1 -4
View File
@@ -15,10 +15,7 @@ migration:
- platform: root - platform: root
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: android - platform: windows
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: linux
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
+1
View File
@@ -12,3 +12,4 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
/app/src/main/jniLibs/
+604 -13
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,45 +7,634 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// SDD-073 / SDD-109 trace: load release-signing material from a
// developer-local gradle.properties file (preferred) or from environment
// variables (CI). Key custody is governed by SDD-073 item 5 and the
// CI signing assertion hook in SDD-109 item 4. NO key material is ever
// committed to the repository; the four properties below are looked up
// at configure time and may be absent on developer machines.
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
fun resolveSigningProp(name: String): String? {
// Precedence: gradle property -> key.properties file -> environment variable.
val fromProject = if (project.hasProperty(name)) project.property(name)?.toString() else null
val fromFile = keystoreProperties.getProperty(name)
val fromEnv = System.getenv(name)
return fromProject ?: fromFile ?: fromEnv
}
val releaseStoreFile = resolveSigningProp("CHANORA_RELEASE_STORE_FILE")
val releaseStorePassword = resolveSigningProp("CHANORA_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = resolveSigningProp("CHANORA_RELEASE_KEY_ALIAS")
val releaseKeyPassword = resolveSigningProp("CHANORA_RELEASE_KEY_PASSWORD")
val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() }
val splitPerAbiRequested = project.hasProperty("split-per-abi") ||
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("split-per-abi", ignoreCase = true)
}
val configuredAbiSet = listOf("arm64-v8a", "x86_64")
val flutterTargetPlatformArg = gradle.startParameter.projectProperties["target-platform"]
?.split(',')
?.map { it.trim() }
?.firstOrNull()
val singleAbiFromFlutterTargetPlatform = when (flutterTargetPlatformArg) {
"android-arm64" -> "arm64-v8a"
"android-x64" -> "x86_64"
else -> null
}
val effectivePackagingAbis: List<String> = when {
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
splitPerAbiRequested -> configuredAbiSet
else -> configuredAbiSet
}
android { android {
namespace = "app.chanora.chanora_flutter" namespace = "app.chanora.chanora_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
} }
defaultConfig { defaultConfig {
applicationId = "app.chanora.chanora_flutter" applicationId = "app.chanora.chanora_flutter"
// DEC-004 (register v0.9.5): Android minimum API 28 (Android 9.0). // DEC-004 (register v0.9.5) / SDD-073 item 1: Android minimum API
// Raised from the original recommendation of API 24 by owner ruling // 28 (Android 9.0). Raised from the original recommendation of
// on 2026-05-14 for a simpler audio path (AAudio stable from API 28) // API 24 by owner ruling on 2026-05-14 for a simpler audio path
// and a narrower compatibility / scoped-storage surface. Do NOT // (AAudio stable from API 28) and a narrower compatibility /
// lower without re-opening DEC-004. // scoped-storage surface. Do NOT lower without re-opening DEC-004.
minSdk = 28 minSdk = 28
// DEC-005: target the Google Play-required API level on upload date. // DEC-005 / SDD-073 item 2 / SRS-188: target the Google
// Flutter's default is kept; release-time CI must verify this still // Play-required API level on upload date. Flutter's default is
// satisfies the current Play policy. // kept; release-time CI must verify this still satisfies the
// current Play policy (SDD-109 release-inspection hook).
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
// SDD-073 item 4: NDK ABIs pinned to arm64-v8a, armeabi-v7a,
// and x86_64. Other ABIs (x86, mips, …) shall not be packaged.
// Per-ABI delivery is handled by the AAB bundle splits below
// (SDD-109 item 2) rather than a fat APK.
// When Flutter --split-per-abi is active, skip ndk.abiFilters to avoid
// conflicting with the Gradle splits.abi mechanism that Flutter injects.
// The per-ABI filtering is still handled by the jniLibs excludes below
// and the Rust cargo-ndk per-ABI build tasks.
if (!splitPerAbiRequested) {
ndk {
// DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
// (arm64-v8a + x86_64) restored after the audiopus_sys
// ANDROID_ABI propagation gap was closed by the workspace
// [patch.crates-io] override pinning `cmake` to the fork
// carrying cmake-rs PR #257 (forwards ANDROID_ABI /
// ANDROID_PLATFORM as -D variables to the child cmake
// invocation). See Cargo.toml [patch.crates-io] block and
// docs/governance/product-decision-register.md DEC-032.
abiFilters += effectivePackagingAbis
}
}
}
// SDD-073 item 5 / SDD-109 item 4: release signing is sourced from
// CI-provided credentials. If any of the four properties is absent,
// the signingConfig is intentionally NOT registered so the release
// build fails fast at task-execution time with a clear message,
// rather than silently falling back to the debug keystore.
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
} }
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // SDD-073 item 5: release builds MUST use a non-debug
// Signing with the debug keys for now, so `flutter run --release` works. // signing config. When the four CHANORA_RELEASE_* inputs
signingConfig = signingConfigs.getByName("debug") // are present (CI), wire the dedicated release signingConfig.
// When they are absent (developer machine without keys),
// leave signingConfig unset so any attempt to assemble a
// release artefact fails with the canonical Gradle error
// "No signing config provided for variant release" — this
// matches the SDD-109 CI signing assertion stance and
// prevents accidental debug-key-signed release outputs.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn(
"[SDD-073] Release signing credentials are not configured " +
"(CHANORA_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / " +
"_KEY_PASSWORD). Release tasks will fail; debug fallback " +
"is intentionally disabled per SDD-073 item 5."
)
}
// SDD-073 item 6: enable R8 + resource shrinking for release.
// Keep rules live in proguard-rules.pro; see that file for
// the JNI / FRB / native-method retention stance.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
// SDD-073 item 6: debug builds run unminified for developer
// ergonomics and stack-trace readability.
isMinifyEnabled = false
} }
} }
packaging {
jniLibs {
if (singleAbiFromFlutterTargetPlatform != null) {
val excludedAbis = configuredAbiSet.filter { it != singleAbiFromFlutterTargetPlatform } +
listOf("armeabi-v7a", "x86")
excludes += excludedAbis.map { abi -> "lib/$abi/**" }
}
}
}
// SDD-109 item 2: Android App Bundle (.aab) split configuration.
// Combined with the SDD-073 abiFilters set, this yields two
// native splits (arm64-v8a, x86_64), per-language resource
// delivery, and per-density resource delivery. Each device
// receives only its matching ABI .so — no cross-ABI bundling.
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
}
}
}
// ONNX Runtime native library for Silero VAD.
// The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
// (`load-dynamic` feature). The AAR ships the .so for arm64-v8a,
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
dependencies {
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
} }
flutter { flutter {
source = "../.." source = "../.."
} }
// ---------------------------------------------------------------------------
// SDD-118: AndroidBridgeBuildAutomation
//
// Auto-build the `chanora_bridge` Rust cdylib via `cargo-ndk` and stage the
// resulting per-ABI `.so` files into `src/main/jniLibs/<abi>/` so AGP picks
// them up during the normal JNI-lib merge step. No manual `cargo ndk`
// invocations and no parent-shell environment mutation are required.
//
// Cross-trace: SDD-073 (Android build config — minSdk, ABI filters),
// SDD-105 (JNI bootstrap — consumer of the produced .so),
// SDD-109 (AAB pipeline — downstream packaging consumer of staged jniLibs).
//
// SDD-118 item 12 (caching across clean builds): cargo's `target/` directory
// lives at the repository root (outside `apps/chanora_flutter/android/`) and
// is intentionally OUTSIDE Gradle's `clean` scope. Do NOT register `target/`
// (or any subpath) as a Gradle output; doing so would make `./gradlew clean`
// destroy cargo's incremental cache.
//
// SDD-118 item 11 (idempotency): Gradle inputs/outputs gate whether the task
// runs at all; cargo's own incremental cache decides whether the run actually
// relinks. No manual timestamp guards.
// ---------------------------------------------------------------------------
// SDD-118 item 2: cargo workspace root (repository root). From the :app
// project (apps/chanora_flutter/android/app) we ascend three levels to reach
// the cargo workspace at the repository root where Cargo.toml / Cargo.lock /
// crates/ live. rootProject.projectDir is the Flutter Android root project
// (apps/chanora_flutter/android), so three levels up reaches the repo root.
val cargoWorkspaceRoot: File = rootProject.projectDir.resolve("../../..").canonicalFile
// SDD-118 item 6 (extended): NDK prebuilt host tag. The NDK ships
// host-specific prebuilt toolchains under
// `$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/<host-tag>/…`; the tag is
// the only path component that varies across developer/CI hosts. Detect
// at configure time so the same Gradle file builds on Linux, macOS, and
// Windows CI runners without manual editing.
val ndkHostTag: String = when {
org.gradle.internal.os.OperatingSystem.current().isMacOsX -> "darwin-x86_64"
org.gradle.internal.os.OperatingSystem.current().isWindows -> "windows-x86_64"
else -> "linux-x86_64"
}
// SDD-118 item 2: minimum API level source of truth — the
// `chanora.android.minSdk` Gradle property mandated by SDD-073 item 1,
// defaulting to 28 per DEC-004. Overridable via `-Pchanora.android.minSdk=…`
// or via `gradle.properties`. Do NOT hard-code a numeric API level elsewhere.
val chanoraAndroidMinSdk: Provider<String> =
providers.gradleProperty("chanora.android.minSdk").orElse("28")
// SDD-118 item 6: explicit Rust-triple ↔ Android-ABI mapping. cargo-ndk
// performs the triple mapping internally; we only pass the Android ABI name
// to its `-t` flag, but we need the Rust triple to locate `target/<triple>/…`
// for the per-ABI copy and for the inputs/outputs declaration.
val abiToRustTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
// SDD-118 item 3: ABI set derived from the existing
// `android.defaultConfig.ndk.abiFilters` declaration owned by SDD-073 item 4
// so that the build-automation list and the AGP packaging list are guaranteed
// in sync. Any unsupported ABI fails fast.
val configuredAbis: List<String> = when {
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
splitPerAbiRequested -> configuredAbiSet
else -> android.defaultConfig.ndk.abiFilters.toList()
}
configuredAbis.forEach { abi ->
require(abiToRustTriple.containsKey(abi)) {
"[SDD-118] ABI '$abi' is not supported by SDD-118; see SDD-073 item 4. " +
"Supported ABIs: ${abiToRustTriple.keys.joinToString(", ")}."
}
}
// SDD-118 item 9: preflight check task. Fails loudly with actionable
// remediation commands if cargo / cargo-ndk / required rustup targets are
// missing. Does NOT auto-install and does NOT mutate user state.
val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
group = "chanora_bridge"
description = "SDD-118 item 9: verify cargo, cargo-ndk, and Android rustup targets are installed."
doLast {
// cargo presence
val cargoResult = providers.exec {
commandLine("cargo", "--version")
isIgnoreExitValue = true
}.result.get()
if (cargoResult.exitValue != 0) {
throw GradleException(
"[SDD-118] `cargo` is not on PATH. Install Rust via https://rustup.rs " +
"and ensure ~/.cargo/bin is on PATH."
)
}
// cargo-ndk presence
val cargoNdkResult = providers.exec {
commandLine("cargo", "ndk", "--version")
isIgnoreExitValue = true
}.result.get()
if (cargoNdkResult.exitValue != 0) {
throw GradleException(
"[SDD-118] `cargo-ndk` is required. Install: cargo install cargo-ndk"
)
}
// rustup targets
val rustupOutput = providers.exec {
commandLine("rustup", "target", "list", "--installed")
isIgnoreExitValue = true
}.standardOutput.asText.get()
val requiredTriples = abiToRustTriple.values.toSet()
val installed = rustupOutput.lines().map { it.trim() }.filter { it.isNotEmpty() }.toSet()
val missing = requiredTriples - installed
if (missing.isNotEmpty()) {
throw GradleException(
"[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
"Install: rustup target add aarch64-linux-android x86_64-linux-android"
)
}
}
}
// SDD-118 item 13 (corrected): one cargo-ndk invocation per ABI rather
// than a single multi-`-t` invocation. The single-shot form is shorter
// but leaks ANDROID_ABI state across iterations, causing audiopus_sys's
// child CMake invocation for x86_64-linux-android to collide with the
// NDK toolchain file's armv7 default. Per-ABI invocation gives
// cargo-ndk a clean ANDROID_ABI per call and isolates failure scope to
// a single ABI.
//
// SDD-118 item 4: profile mapping — Gradle `debug` invokes `cargo build`
// (no `--release`); Gradle `release` invokes `cargo build --release`. We
// register one aggregate task per profile that fans out to one Exec
// sub-task per (profile, ABI) tuple.
fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
val capitalized = profile.replaceFirstChar { it.uppercase() }
// PascalCase ABI suffix for sub-task names (e.g. arm64-v8a -> Arm64V8a).
fun abiToTaskSuffix(abi: String): String =
abi.split('-', '_').joinToString("") { part ->
part.replaceFirstChar { it.uppercase() }
}
// SDD-118 item 5: shared env-var setup. Resolve NDK / CMake toolchain
// path once at configuration time; applied identically to every per-ABI
// sub-task below.
val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
val rustupBinDir = listOf(
"$homeDir/.cargo/bin",
"/opt/homebrew/opt/rustup/bin",
"/usr/local/opt/rustup/bin"
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
// cleanly for both the Rust compile and any child CMake invocation.
val perAbiTaskProviders = configuredAbis.map { abi ->
val triple = abiToRustTriple.getValue(abi)
val abiSuffix = abiToTaskSuffix(abi)
tasks.register<Exec>("buildRustBridge$capitalized$abiSuffix") {
group = "chanora_bridge"
description = "SDD-118 items 1/4/13 (corrected): build chanora_bridge cdylib (profile=$profile, abi=$abi) via cargo-ndk."
dependsOn(checkRustBridgeToolchain)
// SDD-118 item 2: cargo workspace root is the repo root.
workingDir = cargoWorkspaceRoot
// SDD-118 item 13 (corrected): single `-t <abi>` per invocation.
val profileArgs = if (profile == "release") listOf("--release") else emptyList()
val cmd = mutableListOf("cargo", "ndk", "-t", abi)
cmd.add("--platform")
cmd.add(chanoraAndroidMinSdk.get())
cmd.add("--")
cmd.add("build")
cmd.addAll(profileArgs)
cmd.add("-p")
cmd.add("chanora_bridge")
commandLine(cmd)
// SDD-118 item 5 (corrected v3): completely replace the inherited
// environment with a deliberately-minimized map. The Gradle daemon
// accumulates env state from prior per-ABI cargo-ndk invocations
// (CFLAGS, CC, AR, CMAKE_*, RUSTFLAGS); audiopus_sys's cmake crate
// reads these before falling through to the NDK toolchain file's
// per-target settings, causing cross-ABI flag contamination (notably
// armv7's --target=armv7-none-linux-androideabi21 + -march=armv7-a
// leaks into the x86_64 task's clang command line). Resetting the
// env per task isolates each ABI from the rest.
//
// Deliberately NOT propagated (cross-ABI contamination vectors):
// CFLAGS, CXXFLAGS, CPPFLAGS, LDFLAGS, ASFLAGS
// CC, CXX, AR, AS, LD, NM, RANLIB, STRIP, OBJCOPY, OBJDUMP
// CMAKE_C_FLAGS, CMAKE_CXX_FLAGS, CMAKE_C_COMPILER,
// CMAKE_CXX_COMPILER, CMAKE_AR, CMAKE_LINKER, CMAKE_* (other)
// RUSTFLAGS
// Any per-target *_aarch64_linux_android,
// *_armv7_linux_androideabi, *_x86_64_linux_android,
// *_i686_linux_android variants of the above
// Any CARGO_TARGET_* env vars set by an outer build wrapper
val cleanEnv = buildMap<String, String> {
// Pass-through from the calling environment, only the safe set.
System.getenv("PATH")?.let { path ->
put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
}
put("HOME", homeDir)
System.getenv("USER")?.let { put("USER", it) }
System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
System.getenv("TEMP")?.let { put("TEMP", it) }
System.getenv("LANG")?.let { put("LANG", it) }
System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
put("CARGO_HOME", cargoHome)
put("RUSTUP_HOME", rustupHome)
put("RUSTUP_TOOLCHAIN", rustupToolchain)
System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
// Per-task explicit settings (override any pass-through).
// SDD-118 item 5: audiopus_sys needs libopus built from source
// statically because the NDK sysroot ships none.
put("LIBOPUS_STATIC", "1")
put("LIBOPUS_NO_PKG", "1")
put("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
put("ANDROID_NDK_HOME", effectiveNdkPath)
put("ANDROID_NDK_ROOT", effectiveNdkPath)
// SDD-118 item 5 (extended): pin the CMake toolchain file so
// child CMake invocations spawned by build scripts (notably
// audiopus_sys via the `cmake` crate) can locate the Android NDK
// toolchain file at $NDK_HOME/build/cmake/android.toolchain.cmake.
put("CMAKE_TOOLCHAIN_FILE", cmakeToolchainFile)
// SDD-118 item 13 (corrected) — explicitly pin ANDROID_ABI and
// ANDROID_PLATFORM for this invocation. cargo-ndk already sets
// ANDROID_ABI per `-t` flag, but setting it ourselves forecloses
// any chance of a stale value leaking into audiopus_sys's child
// CMake process (the original single-shot bug).
put("ANDROID_ABI", abi)
put("ANDROID_PLATFORM", "android-${chanoraAndroidMinSdk.get()}")
}
// REPLACES the inherited environment (does not augment it).
environment = cleanEnv
// SDD-118 item 8: Gradle up-to-date semantics. Rust source tree,
// workspace manifest, and lockfile are inputs. The single per-ABI
// target/.so is the output of this Exec; the jniLibs copy is a
// separate task with its own outputs (item 7 below).
inputs.dir(cargoWorkspaceRoot.resolve("crates"))
inputs.file(cargoWorkspaceRoot.resolve("Cargo.toml"))
inputs.file(cargoWorkspaceRoot.resolve("Cargo.lock"))
inputs.property("profile", profile)
inputs.property("minSdk", chanoraAndroidMinSdk)
inputs.property("abi", abi)
outputs.file(cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so"))
}
}
// SDD-118 item 13 (corrected): aggregate no-op task. Downstream tasks
// (the copy task, the merge<Variant>JniLibFolders hook) continue to
// depend on `buildRustBridge${Profile}` as before; Gradle's dependsOn
// graph transitively pulls in every per-ABI sub-task. Per-ABI tasks are
// independent and can fail in isolation; Gradle may also choose to run
// them in parallel via its worker pool.
return tasks.register("buildRustBridge$capitalized") {
group = "chanora_bridge"
description = "SDD-118 items 1/4/13 (corrected): aggregate chanora_bridge build (profile=$profile) — fans out to per-ABI sub-tasks."
dependsOn(perAbiTaskProviders)
}
}
val buildRustBridgeDebug = registerCargoNdkBuildTask("debug")
val buildRustBridgeRelease = registerCargoNdkBuildTask("release")
// SDD-118 item 6: per-ABI plain copy (no symlinks, no fat binary) from
// target/<triple>/<profile>/libchanora_bridge.so into
// src/main/jniLibs/<abi>/libchanora_bridge.so. Overwrites prior staging.
//
// SDD-118 item 6 (extended): also stage libc++_shared.so from the
// NDK sysroot. libchanora_bridge.so is dynamically linked against
// the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
// the Android dynamic loader fails at first library load with
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
//
// SDD-118 item 6 (extended): Android-ABI ↔ NDK-sysroot-triple map.
// Note this is NOT the same as `abiToRustTriple`: the NDK sysroot
// uses `arm-linux-androideabi` for 32-bit ARM whereas Rust uses
// `armv7-linux-androideabi`. The sysroot directory names are the
// authoritative source; verified by listing
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
val capitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug
// SDD-118 item 6 (extended): resolve NDK path the same way the
// cargo-ndk task does (env override, fall back to AGP default).
val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
return tasks.register("copyRustBridgeJniLibs$capitalized") {
group = "chanora_bridge"
description = "SDD-118 item 6 (extended): strip and stage per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) into src/main/jniLibs/."
dependsOn(buildTask)
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs").asFile
val llvmStrip = file(
"$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/bin/llvm-strip"
)
doFirst {
jniLibsDir
.listFiles()
?.filter { it.isDirectory && !configuredAbis.contains(it.name) }
?.forEach { staleDir ->
staleDir.deleteRecursively()
}
}
configuredAbis.forEach { abi ->
val triple = abiToRustTriple.getValue(abi)
val sysrootTriple = abiToNdkSysrootTriple.getValue(abi)
val bridgeSo = cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so")
val strippedBridgeSo = layout.buildDirectory
.file("intermediates/stripped_rust_jni/$profile/$abi/libchanora_bridge.so")
.get()
.asFile
// SDD-118 item 6 (extended): also stage libc++_shared.so from the
// NDK sysroot. libchanora_bridge.so is dynamically linked against
// the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
// the Android dynamic loader fails at first library load with
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
val cxxSharedSo = file(
"$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/sysroot/usr/lib/$sysrootTriple/libc++_shared.so"
)
// SDD-118 item 8: input tracking for both staged sources so
// Gradle correctly invalidates when either changes (e.g. NDK
// version bump replacing libc++_shared.so).
inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
outputs.file(strippedBridgeSo)
outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
outputs.file(File(jniLibsDir, "$abi/libc++_shared.so"))
doLast {
val abiDir = File(jniLibsDir, abi)
abiDir.mkdirs()
strippedBridgeSo.parentFile.mkdirs()
bridgeSo.copyTo(strippedBridgeSo, overwrite = true)
project.exec {
commandLine(
llvmStrip.absolutePath,
"--strip-debug",
strippedBridgeSo.absolutePath,
)
}
strippedBridgeSo.copyTo(
File(abiDir, "libchanora_bridge.so"),
overwrite = true,
)
cxxSharedSo.copyTo(
File(abiDir, "libc++_shared.so"),
overwrite = true,
)
}
}
}
}
val copyRustBridgeJniLibsDebug = registerJniLibsCopyTask("debug")
val copyRustBridgeJniLibsRelease = registerJniLibsCopyTask("release")
// SDD-118 item 10: release-inspection assertion. After the copy step and
// before the AGP merge-JniLibFolders node, verify each expected .so exists
// at its destination and is larger than 1 KiB. Catches silent cargo-ndk
// task skips and empty-link-output regressions before they ship into an AAB.
fun registerAssertStagedTask(profile: String): TaskProvider<Task> {
val capitalized = profile.replaceFirstChar { it.uppercase() }
val copyTask = if (profile == "release") copyRustBridgeJniLibsRelease else copyRustBridgeJniLibsDebug
return tasks.register("assertRustBridgeStaged$capitalized") {
group = "chanora_bridge"
description = "SDD-118 item 10 (extended): assert per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) are staged and >1 KiB."
dependsOn(copyTask)
doLast {
configuredAbis.forEach { abi ->
// SDD-118 item 10 (extended): assert both the bridge .so and
// the co-staged libc++_shared.so. Missing C++ runtime is the
// exact failure mode that produced the
// `cannot locate symbol "__cxa_pure_virtual"` cold-launch crash.
listOf("libchanora_bridge.so", "libc++_shared.so").forEach { soName ->
val staged = layout.projectDirectory
.file("src/main/jniLibs/$abi/$soName").asFile
if (!staged.exists()) {
throw GradleException(
"[SDD-118 item 10] Missing staged native library '$soName' for ABI '$abi' " +
"(profile=$profile): expected at ${staged.absolutePath}. " +
"Did cargo-ndk fail or silently skip, or is the NDK sysroot missing libc++_shared.so?"
)
}
if (staged.length() <= 1024) {
throw GradleException(
"[SDD-118 item 10] Staged native library '$soName' for ABI '$abi' " +
"(profile=$profile) is trivially small (${staged.length()} bytes) " +
"at ${staged.absolutePath}. Suspected empty-link-output regression."
)
}
}
}
}
}
}
val assertRustBridgeStagedDebug = registerAssertStagedTask("debug")
val assertRustBridgeStagedRelease = registerAssertStagedTask("release")
// SDD-118 item 7: task-graph wiring. Attach the build+copy+assert chain to
// AGP's per-variant `merge<Variant>JniLibFolders` task. This is the correct
// graph node — earlier than preBuild would over-trigger (e.g. IDE sync),
// later would race AGP's jniLibs packaging.
tasks.matching {
it.name.startsWith("merge") && it.name.endsWith("JniLibFolders")
}.configureEach {
val lowerName = name.lowercase()
val isRelease = lowerName.contains("release")
dependsOn(
if (isRelease) assertRustBridgeStagedRelease else assertRustBridgeStagedDebug
)
}
+63
View File
@@ -0,0 +1,63 @@
# SDD-trace: SDD-073 item 6 (R8 / ProGuard stance) + SDD-105 AndroidJniBootstrap.
#
# Release builds run with isMinifyEnabled = true and isShrinkResources = true
# (see app/build.gradle.kts). This file lists the minimum keep rules
# required so that R8 does not strip symbols reachable only from native
# code, JNI, or reflection.
#
# Scope of keeps:
# 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105).
# 2. Native methods (declared with the `native` keyword) anywhere in
# the app module these are looked up by signature from C/Rust.
# 3. The Android voice foreground service (SDD-107) referenced from
# the manifest by FQN and from JNI via static start/stop helpers.
# 4. The Android audio mode controller (SDD-108) audio mode is
# managed in Rust via direct JNI (engine.rs), not a Kotlin class.
# 5. Kotlin metadata required by FRB-generated bindings (SDD-079).
# --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) -----------
# JNI surface these classes are referenced by FQN from AndroidManifest.xml,
# from native code (Rust/JNI lookups), or from FRB-generated bindings, so R8
# must NOT rename/remove them. All other app.chanora.** classes are
# consumed only from Kotlin and remain eligible for R8 shrink/optimize.
-keep class app.chanora.chanora_flutter.MainActivity { *; }
-keep class app.chanora.chanora_flutter.ChanoraApplication { *; }
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
-keep class app.chanora.chanora_flutter.AndroidPermissionRequester { *; }
-keep class app.chanora.chanora_flutter.BackIntentBridge { *; }
# SDD-109 / SDD-110 / SDD-111: JNI-referenced voice controllers.
# Called from the Rust audio engine via JNI static methods.
-keep class app.chanora.chanora_flutter.AndroidAudioFocusController { *; }
-keep class app.chanora.chanora_flutter.AndroidBluetoothScoController { *; }
-keep class app.chanora.chanora_flutter.AndroidAudioLifecycleController { *; }
-keep class io.flutter.plugins.** { *; }
# flutter_rust_bridge generated bindings (SDD-079 TypedBridgeFacade) keep
# the public surface so R8 does not rename or remove the symbols invoked
# from the native side.
-keep class ** implements io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
-keepclassmembers class * {
@io.flutter.plugin.common.MethodChannel$MethodCallHandler *;
}
# --- 2. Native methods (declared with `native` in Kotlin/Java) ---------------
-keepclasseswithmembernames class * {
native <methods>;
}
# --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------
# Covered by the JNI-surface keep block above.
# --- 4. Android Audio Mode Controller (SDD-108) -------------------------
# SDD-108 in-call audio mode (AudioManager.setMode) is implemented in the
# Rust audio engine (crates/chanora_audio/src/engine.rs) via direct JNI,
# not through a Kotlin controller class. No keep rule is needed; the
# engine.rs android_set_audio_mode / android_get_audio_mode helpers
# resolve AudioManager at runtime via ndk_context.
# --- 5. Kotlin metadata + serialisation (SDD-079) ----------------------------
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
-keep class kotlin.Metadata { *; }
# Defer to Flutter's own keep rules for the embedding layer; the Flutter
# Gradle plugin contributes those automatically.
@@ -1,8 +1,75 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- SDD-trace: SRS-045 (protocol adapter dials TeamSpeak-compatible
server) / SAD-032 (protocol-adapter isolation). Also supports
SRS-130 network-failure error reporting. Required for the
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- SRS-100: Network diagnostics. Allows the core to query active
network type and state for the diagnostic export. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- SDD-115: Prevent CPU sleep during active voice sessions.
Keeps the audio pipeline running when the screen locks. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- SRS-025: Haptic feedback on PTT key press. Enables
VibrationEffect for transmit-mode tactile confirmation. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
Required for the audio engine (chanora_audio) to open the
capture stream for voice transmission. The runtime grant
must still be requested; this declaration only allows the
app to ask. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
Required on API 28+ to start a foreground service that keeps
the voice session alive while the UI is backgrounded. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
Required on API 34+ when the foreground service declares
foregroundServiceType="microphone". Declared here for the
entire API ladder; the platform ignores it on older releases. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
Required on API 33+ to display the ongoing voice-session
notification. Runtime-requested via AndroidPermissionRequester
(SDD-106); denial does not block the service. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- SDD-trace: SDD-108 AndroidAudioModeController.
Required for AudioManager.setMode(MODE_IN_COMMUNICATION) and
related in-call audio routing operations. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
BLUETOOTH_ADMIN required for startBluetoothSco() / stopBluetoothSco()
on API 23-30. Superseded by BLUETOOTH_CONNECT on API 31+. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
BLUETOOTH required to query BluetoothAdapter on API < 31. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<!-- SDD-trace: Android Oboe voice backend; supports SCO/BLE
headset routing on API 31+. Declared here so the platform
allows querying / connecting to bonded Bluetooth audio devices
for the voice session. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- SDD-105: Application class loads chanora_bridge native library before MainActivity onCreate -->
<application <application
android:label="chanora_flutter" android:label="Chanora"
android:name="${applicationName}" android:name="app.chanora.chanora_flutter.ChanoraApplication"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -25,6 +92,26 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 2).
Manifest declaration for the foreground service that owns
the Android voice-session lifecycle. The Kotlin class is
owned by Wave 2B-2 (apps/chanora_flutter/android/app/src/main/kotlin/).
android:foregroundServiceType="microphone" is required by
API 30+ to gate background microphone access; ignored on
API 28-29 where capture is permitted without it. -->
<service
android:name="app.chanora.chanora_flutter.AndroidVoiceForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
<!-- flutter_foreground_task: keeps the Flutter engine alive when
connected to a server so voice chat is not killed in background. -->
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
@@ -0,0 +1,162 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
/**
* Manages Android audio focus (requestAudioFocus / abandonAudioFocus) for
* the Chanora voice session.
*
* Trace: SDD-109 (Android Audio Focus)
*
* ## Lifecycle
*
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
* voice streams are opened. Requests `AUDIOFOCUS_GAIN` for
* `USAGE_VOICE_COMMUNICATION` / `CONTENT_TYPE_SPEECH` and registers
* an `OnAudioFocusChangeListener`.
* 2. Focus-change callbacks are forwarded to the Rust engine via
* `publishFocusChange(int)`, a JNI function declared in
* `crates/chanora_audio/src/android_voice_unit.rs`.
* 3. [stop] — called by the Rust engine on voice stop. Abandons focus
* and clears the listener.
*
* ## Thread model
*
* `start` / `stop` are called from a tokio worker thread (via JNI), not
* the Android main thread. The `AudioManager` API is thread-safe.
* `OnAudioFocusChangeListener` callbacks arrive on the main thread; we
* forward to Rust via JNI which attaches the calling thread to the JVM.
*/
internal class AndroidAudioFocusController {
companion object {
private const val TAG = "ChanoraAudioFocus"
/**
* JNI entry point implemented in
* `crates/chanora_audio/src/android_voice_unit.rs`.
*
* Kotlin calls this from [OnAudioFocusChangeListener] to forward
* the focus-change integer to the Rust engine's BackendEvent channel.
*/
@JvmStatic
external fun publishFocusChange(state: Int)
/**
* Start audio focus management.
*
* Called from Rust via JNI after voice unit start.
* Idempotent: repeated calls against an already-started instance
* are silently ignored.
*/
@JvmStatic
fun start(context: Context) {
if (focusRequested) {
Log.d(TAG, "start() called but focus already held; no-op")
return
}
requestFocus(context)
}
/**
* Stop audio focus management.
*
* Called from Rust via JNI on voice stop.
* Idempotent: safe to call when no focus is held.
*/
@JvmStatic
fun stop(context: Context) {
if (!focusRequested) {
Log.d(TAG, "stop() called but no focus held; no-op")
return
}
abandonFocus(context)
}
private var focusRequested: Boolean = false
private var focusRequestHandle: AudioFocusRequest? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
Log.i(TAG, "onAudioFocusChange: $focusChange")
try {
publishFocusChange(focusChange)
} catch (t: Throwable) {
Log.w(TAG, "publishFocusChange JNI failed: ${t.message}", t)
}
}
private fun requestFocus(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot request audio focus")
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attr = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attr)
.setOnAudioFocusChangeListener(audioFocusListener, mainHandler)
.build()
val result = am.requestAudioFocus(request)
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (focusRequested) {
focusRequestHandle = request
}
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
} else {
@Suppress("DEPRECATION")
val result = am.requestAudioFocus(
audioFocusListener,
AudioManager.STREAM_VOICE_CALL,
AudioManager.AUDIOFOCUS_GAIN,
)
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
}
} catch (e: SecurityException) {
Log.e(TAG, "requestAudioFocus denied by platform: ${e.message}", e)
focusRequested = false
}
}
private fun abandonFocus(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot abandon audio focus")
focusRequested = false
focusRequestHandle = null
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val handle = focusRequestHandle
focusRequestHandle = null
if (handle != null) {
am.abandonAudioFocusRequest(handle)
}
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(audioFocusListener)
}
Log.i(TAG, "abandonAudioFocus dispatched")
} catch (e: SecurityException) {
Log.w(TAG, "abandonAudioFocus failed: ${e.message}", e)
}
focusRequested = false
focusRequestHandle = null
}
}
}
@@ -0,0 +1,220 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* Android audio lifecycle controller.
*
* Mirrors the iOS `AppDelegate` audio lifecycle events — route changes,
* device connectivity changes, and app lifecycle transitions — and
* forwards them to the Dart layer over a MethodChannel. The Dart side
* dispatches these to the Rust bridge, the same pattern as
* `_wireIosAudioLifecycle()` in `main.dart`.
*
* Trace: SDD-111 (MobileVoiceAudioBackend cross-platform)
*
* ## Events published
*
* `routeChange` — audio device connected/disconnected (payload:
* `{"routeType": String}`). Mimics iOS
* `handleRouteChange`.
* `interruptionBegan` — audio interruption started (e.g. phone call).
* `interruptionEnded` — audio interruption ended, with `shouldResume`.
* `appDidEnterBackground` — app moved to background.
* `appWillEnterForeground` — app returned to foreground.
* `mediaServicesReset` — equivalent of iOS media services reset
* (Android: audio devices changed significantly).
*
* ## Thread model
*
* All callbacks from `AudioDeviceCallback` arrive on the main thread
* (registered with the main `Handler`). App lifecycle observation is
* driven by the Flutter `AppLifecycleListener` on the Dart side;
* this controller only owns the audio-device side. The Flutter side
* is responsible for wiring lifecycle and forwarding events to Rust.
*/
internal class AndroidAudioLifecycleController(
private val context: Context,
) {
private val audioManager: AudioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val mainHandler = Handler(Looper.getMainLooper())
private var callbackRegistered = false
private var currentRouteFingerprint: String? = null
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
notifyRouteChange(addedDevices.firstOrNull())
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
notifyRouteChange(removedDevices.firstOrNull())
}
}
private var channel: MethodChannel? = null
/**
* Attach to [flutterEngine]'s binary messenger.
*
* Creates a MethodChannel named `chanora/android_audio_lifecycle`
* and starts observing audio device changes.
*/
fun attach(flutterEngine: FlutterEngine) {
channel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL_NAME,
)
startObservingAudioDevices()
Log.i(TAG, "attached to flutter engine")
}
/**
* Detach from the Flutter engine and stop observing.
*/
fun detach() {
stopObservingAudioDevices()
channel?.setMethodCallHandler(null)
channel = null
Log.i(TAG, "detached")
}
/**
* Call from `Activity.onResume` to re-evaluate the current route.
*/
fun onResume() {
notifyRouteChange(null)
}
/**
* Call from `Activity.onDestroy` to tear down.
*/
fun onDestroy() {
detach()
}
private fun startObservingAudioDevices() {
if (callbackRegistered) return
audioManager.registerAudioDeviceCallback(audioDeviceCallback, mainHandler)
callbackRegistered = true
Log.d(TAG, "audio device observer started")
}
private fun stopObservingAudioDevices() {
if (!callbackRegistered) return
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
callbackRegistered = false
Log.d(TAG, "audio device observer stopped")
}
private fun notifyRouteChange(
device: AudioDeviceInfo?,
) {
val routeType = classifyCurrentRoute(device)
val routeFingerprint = buildRouteFingerprint(routeType)
if (routeFingerprint == currentRouteFingerprint) {
return
}
currentRouteFingerprint = routeFingerprint
Log.i(TAG, "route changed to: $routeType fingerprint=$routeFingerprint")
channel?.invokeMethod(
"handleRouteChange",
mapOf("routeType" to routeType),
)
}
private fun buildRouteFingerprint(routeType: String): String {
val selectedCommunicationId =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.communicationDevice?.id?.toString().orEmpty()
} else {
""
}
val inputs = audioManager
.getDevices(AudioManager.GET_DEVICES_INPUTS)
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
.sorted()
.joinToString("|")
val outputs = audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
.sorted()
.joinToString("|")
return "$routeType#$selectedCommunicationId#$inputs#$outputs"
}
/**
* Classify the current audio output route into a stable string
* matching the iOS route-classification schema so the Dart-side
* parser (`_parseBridgeAudioRoute`) works identically across
* platforms.
*/
private fun classifyCurrentRoute(specificDevice: AudioDeviceInfo?): String {
// If a specific device was added/removed, prefer its type.
if (specificDevice != null) {
return classifyDevice(specificDevice)
}
// Otherwise, classify based on the current output devices.
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
if (outputs.isEmpty()) return "Unknown"
// Prefer wired/Bluetooth headset if connected.
for (d in outputs) {
val t = classifyDevice(d)
when (t) {
"WiredHeadset", "BluetoothHfp", "BluetoothA2dp", "UsbHeadset" -> return t
else -> {}
}
}
// Fall back to the first output device classification.
val first = classifyDevice(outputs.first())
return when (first) {
"Speaker" -> "Speaker"
"Earpiece" -> "Earpiece"
else -> "Speaker" // Default to Speaker for unknown outputs.
}
}
private fun classifyDevice(d: AudioDeviceInfo): String = when (d.type) {
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Earpiece"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WiredHeadset"
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothHfp"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2dp"
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_SPEAKER -> if (Build.VERSION.SDK_INT >= 31) "BluetoothHfp" else "BluetoothA2dp"
AudioDeviceInfo.TYPE_USB_HEADSET -> "UsbHeadset"
AudioDeviceInfo.TYPE_HDMI -> "Hdmi"
else -> {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
d.type == AudioDeviceInfo.TYPE_BLE_BROADCAST
) {
"BluetoothA2dp"
} else {
"Unknown"
}
}
}
companion object {
private const val TAG = "ChanoraAudioLifecycle"
/**
* MethodChannel name for Android audio lifecycle events.
* Mirrors `chanora/ios_audio_lifecycle` on iOS.
*/
const val CHANNEL_NAME: String = "chanora/android_audio_lifecycle"
}
}
@@ -0,0 +1,265 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
internal class AndroidAudioOutputController(context: Context) :
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler {
private val appContext = context.applicationContext
private val audioManager: AudioManager = appContext.getSystemService(AudioManager::class.java)
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
private var callbackRegistered = false
private val deviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
emitDeviceChanged()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
emitDeviceChanged()
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
MethodChannels.METHOD_GET_OUTPUT_DEVICES -> result.success(getOutputDevices())
MethodChannels.METHOD_GET_COMMUNICATION_DEVICES -> result.success(getCommunicationDevices())
MethodChannels.METHOD_SET_COMMUNICATION_DEVICE -> {
val deviceId = call.argument<String>("deviceId")
if (deviceId.isNullOrBlank()) {
result.error("missing_device_id", "deviceId is required", null)
} else {
result.success(setCommunicationDevice(deviceId))
}
}
MethodChannels.METHOD_CLEAR_COMMUNICATION_DEVICE -> {
clearCommunicationDevice()
result.success(null)
}
else -> result.notImplemented()
}
} catch (t: Throwable) {
result.error("audio_output_failed", t.message, null)
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
startObserveAudioDevices()
}
override fun onCancel(arguments: Any?) {
stopObserveAudioDevices()
eventSink = null
}
fun detach() {
stopObserveAudioDevices()
eventSink = null
}
private fun getOutputDevices(): List<Map<String, Any>> {
val communicationDevices = communicationDevices()
val communicationIds = CommunicationDeviceIds(
selectedId = selectedCommunicationDeviceId(),
availableIds = communicationDevices.map { it.id.toString() }.toSet(),
availableTypes = communicationDevices.map { it.type }.toSet(),
)
return audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.map { device ->
val dto = device.toDto(communicationIds.selectedId, communicationIds.availableIds)
dto + ("isAvailableForCommunication" to (
dto["isAvailableForCommunication"] == true ||
communicationIds.availableTypes.contains(device.type)
))
}
}
private fun getCommunicationDevices(): List<Map<String, Any>> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val selectedId = selectedCommunicationDeviceId()
val devices = communicationDevices()
val availableIds = devices.map { it.id.toString() }.toSet()
return devices.map { it.toDto(selectedId, availableIds) }
}
return audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.map { it.toDto(selectedId = null, communicationIds = emptySet()) }
}
private fun setCommunicationDevice(deviceId: String): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
Log.w(TAG, "setCommunicationDevice unsupported below API 31 deviceId=$deviceId")
return false
}
val communicationDevices = communicationDevices()
val outputDevice = audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.firstOrNull { it.id.toString() == deviceId }
val target = communicationDevices.firstOrNull { it.id.toString() == deviceId }
?: outputDevice?.let { output ->
communicationDevices.firstOrNull { it.type == output.type }
}
if (target == null) {
Log.w(
TAG,
"setCommunicationDevice target not available deviceId=$deviceId outputs=${audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).joinToString { it.logLabel() }} communication=${communicationDevices.joinToString { it.logLabel() }}",
)
return false
}
if (target.requiresBluetoothConnectPermission() &&
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
Log.w(
TAG,
"setCommunicationDevice blocked for bluetooth target=${target.logLabel()}: BLUETOOTH_CONNECT not granted",
)
return false
}
val changed = audioManager.setCommunicationDevice(target)
Log.i(TAG, "setCommunicationDevice device=${target.logLabel()} changed=$changed selected=${audioManager.communicationDevice?.logLabel()}")
return changed
}
private fun clearCommunicationDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
Log.i(TAG, "clearCommunicationDevice selected=${audioManager.communicationDevice?.logLabel()}")
}
}
private fun startObserveAudioDevices() {
if (callbackRegistered) return
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
callbackRegistered = true
}
private fun stopObserveAudioDevices() {
if (!callbackRegistered) return
audioManager.unregisterAudioDeviceCallback(deviceCallback)
callbackRegistered = false
}
private fun emitDeviceChanged() {
mainHandler.post {
eventSink?.success(mapOf("type" to "audioDeviceChanged"))
}
}
private fun communicationDeviceIds(): CommunicationDeviceIds {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return CommunicationDeviceIds(
selectedId = null,
availableIds = emptySet(),
availableTypes = emptySet(),
)
}
val devices = communicationDevices()
return CommunicationDeviceIds(
selectedId = selectedCommunicationDeviceId(),
availableIds = devices.map { it.id.toString() }.toSet(),
availableTypes = devices.map { it.type }.toSet(),
)
}
private fun selectedCommunicationDeviceId(): String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.communicationDevice?.id?.toString()
} else {
null
}
private fun communicationDevices(): List<AudioDeviceInfo> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.availableCommunicationDevices
} else {
emptyList()
}
private fun AudioDeviceInfo.toDto(
selectedId: String?,
communicationIds: Set<String>,
): Map<String, Any> {
val id = this.id.toString()
val normalizedType = normalizedType()
return mapOf(
"id" to id,
"name" to displayName(normalizedType),
"type" to normalizedType,
"isSelected" to (selectedId == id),
"isAvailableForCommunication" to communicationIds.contains(id),
)
}
private fun AudioDeviceInfo.displayName(normalizedType: String): String {
val product = productName?.toString()?.trim().orEmpty()
if (product.isNotEmpty()) return product
return when (normalizedType) {
"speaker" -> "Speaker"
"earpiece" -> "Earpiece"
"wiredHeadset", "wiredHeadphones" -> "Wired Headset"
"bluetoothA2dp", "bluetoothSco", "bluetoothLe" -> "Bluetooth Headset"
"usbHeadset" -> "USB Headset"
"hdmi" -> "HDMI"
else -> "Other Device"
}
}
private fun AudioDeviceInfo.normalizedType(): String = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "earpiece"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wiredHeadset"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "wiredHeadphones"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bluetoothA2dp"
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bluetoothSco"
AudioDeviceInfo.TYPE_USB_HEADSET -> "usbHeadset"
AudioDeviceInfo.TYPE_HDMI -> "hdmi"
else -> if (isBluetoothLe()) "bluetoothLe" else "unknown"
}
private fun AudioDeviceInfo.isBluetoothLe(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
return type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)
}
private fun AudioDeviceInfo.requiresBluetoothConnectPermission(): Boolean =
normalizedType() == "bluetoothA2dp" ||
normalizedType() == "bluetoothSco" ||
normalizedType() == "bluetoothLe"
private fun AudioDeviceInfo.logLabel(): String =
"id=$id type=${normalizedType()} product=${productName?.toString()?.trim().orEmpty()}"
private data class CommunicationDeviceIds(
val selectedId: String?,
val availableIds: Set<String>,
val availableTypes: Set<Int>,
)
private companion object {
const val TAG = "ChanoraAudioOutput"
}
}
@@ -0,0 +1,353 @@
package app.chanora.chanora_flutter
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothProfile
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
/**
* Manages Android Bluetooth voice routing for the Chanora voice session.
*
* Trace: SDD-110 (Android Bluetooth SCO)
*
* On Android 13 / API 33 and newer, VoIP apps are expected to select a
* Bluetooth communication route with `AudioManager.setCommunicationDevice()`
* so BLE audio headsets are supported. On older Android releases we fall
* back to legacy SCO start / stop management.
*
* ## Lifecycle
*
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
* voice streams are opened. On API 33+ this prefers
* `AudioManager.setCommunicationDevice()` for Bluetooth communication
* devices. On older releases it calls `AudioManager.startBluetoothSco()`
* if a Bluetooth SCO-capable device is connected and registers a
* `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`.
* 2. Legacy SCO state changes, or synthetic connected/disconnected state
* changes for the API 33+ path, are forwarded to the Rust engine via
* `publishScoStateChange(int)`, a JNI function declared in
* `crates/chanora_audio/src/android_voice_unit.rs`.
* 3. [stop] — called by the Rust engine on voice stop. Clears the selected
* communication device on API 33+ when it is one we selected, otherwise
* falls back to `AudioManager.stopBluetoothSco()` and receiver teardown.
*
* ## Thread model
*
* `start` / `stop` are called from a tokio worker thread (via JNI).
* `AudioManager.startBluetoothSco` is asynchronous on legacy devices —
* the platform responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives
* on the main thread via the `BroadcastReceiver`.
*/
internal class AndroidBluetoothScoController {
companion object {
private const val TAG = "ChanoraBluetoothSco"
/**
* JNI entry point implemented in
* `crates/chanora_audio/src/android_voice_unit.rs`.
*
* Kotlin calls this from the SCO state `BroadcastReceiver` to
* forward the state integer to the Rust engine's BackendEvent
* channel.
*/
@JvmStatic
external fun publishScoStateChange(state: Int)
/**
* Start Bluetooth SCO management.
*
* Called from Rust via JNI after voice unit start.
* Idempotent: repeated calls against an already-started instance
* are silently ignored.
*/
@JvmStatic
fun start(context: Context) {
val appContext = context.applicationContext
if (scoStarted) {
Log.d(TAG, "start() called but SCO already active; no-op")
return
}
scoStarted = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (trySelectBluetoothCommunicationDevice(appContext)) {
return
}
Log.i(
TAG,
"No selectable Bluetooth communication device on API 33+; legacy SCO fallback is skipped",
)
return
}
registerScoReceiver(appContext)
tryStartSco(appContext)
}
/**
* Stop Bluetooth SCO management.
*
* Called from Rust via JNI on voice stop.
* Idempotent: safe to call when SCO is not active.
*/
@JvmStatic
fun stop(context: Context) {
val appContext = context.applicationContext
scoStarted = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
clearSelectedBluetoothCommunicationDevice(appContext)
return
}
unregisterScoReceiver(appContext)
tryStopSco(appContext)
}
private var scoStarted: Boolean = false
private var receiverRegistered: Boolean = false
private var selectedCommunicationDeviceId: Int? = null
private val scoReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) return
val state = intent.getIntExtra(
AudioManager.EXTRA_SCO_AUDIO_STATE,
AudioManager.SCO_AUDIO_STATE_ERROR,
)
val prevState = intent.getIntExtra(
AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE,
-1,
)
Log.i(
TAG,
"SCO state: ${scoStateName(state)} (prev: ${scoStateName(prevState)})",
)
try {
publishScoStateChange(state)
} catch (t: Throwable) {
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
}
}
}
private fun registerScoReceiver(context: Context) {
if (receiverRegistered) return
try {
val filter = IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(scoReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(scoReceiver, filter)
}
receiverRegistered = true
Log.d(TAG, "SCO receiver registered")
} catch (e: Exception) {
Log.w(TAG, "Failed to register SCO receiver: ${e.message}", e)
}
}
private fun unregisterScoReceiver(context: Context) {
if (!receiverRegistered) return
try {
context.unregisterReceiver(scoReceiver)
receiverRegistered = false
Log.d(TAG, "SCO receiver unregistered")
} catch (e: IllegalArgumentException) {
// Already unregistered — ignore silently.
receiverRegistered = false
}
}
private val bluetoothAdapter: BluetoothAdapter?
get() = try {
BluetoothAdapter.getDefaultAdapter()
} catch (e: SecurityException) {
Log.w(TAG, "BluetoothAdapter unavailable: ${e.message}")
null
}
private fun isBluetoothScoOn(am: AudioManager): Boolean = try {
am.isBluetoothScoOn
} catch (e: SecurityException) {
Log.w(TAG, "isBluetoothScoOn failed: ${e.message}")
false
}
private fun isBluetoothScoAvailableOffCall(am: AudioManager): Boolean = try {
am.isBluetoothScoAvailableOffCall
} catch (e: SecurityException) {
Log.w(TAG, "isBluetoothScoAvailableOffCall failed: ${e.message}")
false
}
private fun tryStartSco(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
return
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping SCO start")
return
}
if (isBluetoothScoOn(am)) {
Log.i(TAG, "SCO already on; no-op")
return
}
val adapter = bluetoothAdapter
if (adapter == null || !adapter.isEnabled) {
Log.i(TAG, "Bluetooth not enabled; skipping SCO start")
return
}
val scoAvailableOffCall = isBluetoothScoAvailableOffCall(am)
if (!scoAvailableOffCall) {
Log.i(TAG, "Bluetooth SCO not available off-call; skipping SCO start")
return
}
val hasScoHeadset = try {
val state = adapter.getProfileConnectionState(BluetoothProfile.HEADSET)
state == BluetoothProfile.STATE_CONNECTED
} catch (e: SecurityException) {
Log.w(TAG, "getProfileConnectionState(HEADSET) failed: ${e.message}")
false
}
if (!hasScoHeadset) {
Log.i(TAG, "No SCO-capable Bluetooth headset connected; skipping SCO start")
return
}
try {
am.startBluetoothSco()
Log.i(TAG, "startBluetoothSco() dispatched")
} catch (e: SecurityException) {
Log.e(TAG, "startBluetoothSco denied: ${e.message}", e)
}
}
private fun trySelectBluetoothCommunicationDevice(context: Context): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot select communication device")
return false
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping bluetooth communication device selection")
return false
}
val target = am.availableCommunicationDevices.firstOrNull { it.isBluetoothCommunicationDevice() }
if (target == null) {
Log.i(TAG, "No Bluetooth communication device available on API 33+")
return false
}
val current = am.communicationDevice
if (current?.id == target.id) {
selectedCommunicationDeviceId = target.id
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
Log.i(TAG, "Bluetooth communication device already selected: ${target.logLabel()}")
return true
}
val changed = am.setCommunicationDevice(target)
Log.i(
TAG,
"setCommunicationDevice bluetooth target=${target.logLabel()} changed=$changed selected=${am.communicationDevice?.logLabel()}",
)
if (changed) {
selectedCommunicationDeviceId = target.id
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
}
return changed
}
private fun clearSelectedBluetoothCommunicationDevice(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot clear communication device")
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return
}
val selectedId = selectedCommunicationDeviceId
val current = am.communicationDevice
if (selectedId == null || current?.id != selectedId) {
selectedCommunicationDeviceId = null
Log.d(TAG, "No app-selected Bluetooth communication device to clear")
return
}
am.clearCommunicationDevice()
selectedCommunicationDeviceId = null
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_DISCONNECTED)
Log.i(TAG, "clearCommunicationDevice() dispatched for bluetooth route")
}
private fun tryStopSco(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot stop SCO")
return
}
if (!isBluetoothScoOn(am)) {
Log.d(TAG, "SCO not on; no-op")
return
}
try {
am.stopBluetoothSco()
Log.i(TAG, "stopBluetoothSco() dispatched")
} catch (e: SecurityException) {
Log.w(TAG, "stopBluetoothSco denied: ${e.message}", e)
}
}
private fun publishSyntheticScoState(state: Int) {
try {
publishScoStateChange(state)
} catch (t: Throwable) {
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
}
}
private fun AudioDeviceInfo.isBluetoothCommunicationDevice(): Boolean =
type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
(type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)))
private fun AudioDeviceInfo.logLabel(): String =
"id=$id type=$type product=${productName?.toString()?.trim().orEmpty()}"
private fun scoStateName(state: Int): String = when (state) {
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING"
AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR"
else -> "UNKNOWN($state)"
}
}
}
@@ -0,0 +1,487 @@
package app.chanora.chanora_flutter
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
/**
* Android runtime-permission requester for the audio subsystem.
*
* Trace:
* - SDD-106 `AndroidPermissionRequester` (RECORD_AUDIO runtime flow with
* listen-only fallback, settings deep-link for permanent denial,
* revocation handling, bridge event surface).
* - SRS-209 (Android runtime permission UX).
* - SAD-085 (source SAD for this SDD unit).
*
* ## Why Activity-bound, not a global singleton
*
* Android runtime permission requests are inherently tied to a
* concrete [Activity] (the system dialog is hosted by the Activity and
* the result is delivered through `onRequestPermissionsResult`). A
* process-wide singleton would have to track which Activity is
* currently in the foreground and would race with configuration
* changes. Binding the requester to the host Activity ([MainActivity],
* owned by Wave 2B-4) keeps the lifecycle simple and audit-clear: one
* requester per Activity instance, no static state to leak across
* Activity recreation.
*
* ## Contract with the host Activity
*
* The host Activity MUST:
* 1. Construct one [AndroidPermissionRequester] in `onCreate`.
* 2. Forward `onRequestPermissionsResult` to
* [handleRequestPermissionsResult].
* 3. Call [onResume] from its `Activity.onResume` so mid-session
* revocation (per SDD-106 §4) is observed.
*
* The Dart-side wiring (publishing `BridgeEvent::PermissionState` over
* the bridge event stream, per SDD-106 §5) is delivered through the
* [MethodChannels.ANDROID_PERMISSIONS] channel. The actual MethodChannel
* handler is wired by [MainActivity] in a follow-up task; for now this
* class exposes [stateChangeListener] and the channel name constant.
*
* ## Thread model
*
* All public methods are expected to be called from the Android main
* thread (Activity lifecycle thread). The Rust audio engine queries
* cached state via a separate JNI helper (SDD-106 §7, out of scope for
* this Kotlin class) — this class does not directly expose state to
* other threads.
*/
class AndroidPermissionRequester {
/**
* Discrete permission-state values surfaced to the host Activity
* and onward to the Rust bridge.
*
* Trace: SDD-106 §5 (state machine), SRS-209.
*
* Note: SDD-106 §5 also lists `Undetermined` as a fourth state.
* That state is internal to the Rust-side cache (initial value
* before the first query); the Kotlin requester never emits it
* because every emission corresponds to a resolved
* `checkSelfPermission` result.
*/
sealed class PermissionState {
/** Permission granted by the user. */
object Granted : PermissionState()
/** Permission denied, but the user may still be re-prompted. */
object Denied : PermissionState()
/**
* Permission denied with "do not ask again" — Android will no
* longer show the system dialog. The UI must deep-link to app
* settings via [openAppSettings].
*
* Trace: SDD-106 §3.
*/
object PermanentlyDenied : PermissionState()
}
private companion object {
private const val TAG = "ChanoraPerm"
/**
* Stable request code for `RECORD_AUDIO`. Must remain stable
* across releases so that result dispatch in
* [handleRequestPermissionsResult] matches the request.
*/
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
private const val REQ_STARTUP_PERMISSIONS = 0x53544152 // "STAR"
/**
* SharedPreferences file backing the cross-process / cross-launch
* "has the user ever been asked for this permission?" flag.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private const val PREFS_FILE = "chanora_permissions"
/**
* Boolean key set to `true` the first time we invoke
* [ActivityCompat.requestPermissions] for `RECORD_AUDIO`.
*
* Contract (M-1 strict-review fix):
* - `false` (default) — the user has never been prompted in
* any prior process for `RECORD_AUDIO`. In this state,
* `shouldShowRequestPermissionRationale == false` means
* "fresh / never asked", NOT "permanently denied".
* - `true` — the user has been prompted at least once in
* some prior (or current) process. In this state,
* `shouldShowRequestPermissionRationale == false` after a
* non-granted result indicates "Do not ask again" /
* permanent denial.
*
* Persisting this across process death is what lets
* [onResume] and [handleRequestPermissionsResult] faithfully
* observe revocation per SDD-106 §4 even when Android killed
* and restarted the process during a settings round-trip.
*/
private const val KEY_RECORD_AUDIO_HAS_REQUESTED = "record_audio_has_requested"
private const val KEY_BLUETOOTH_CONNECT_HAS_REQUESTED = "bluetooth_connect_has_requested"
private const val KEY_POST_NOTIFICATIONS_HAS_REQUESTED = "post_notifications_has_requested"
}
/**
* In-flight callback for the active permission request, if any.
* Cleared in [handleRequestPermissionsResult]. Single-flight is
* enforced by overwriting (the most recent caller wins); concurrent
* `voice_join` coalescing (SDD-106 §8) is owned by the Rust side.
*/
private var pendingCallback: ((PermissionState) -> Unit)? = null
private var pendingStartupCallback: ((Map<String, PermissionState>) -> Unit)? = null
/**
* Optional listener invoked on every resolved state change. The
* MainActivity wires this to a [io.flutter.plugin.common.MethodChannel]
* on [MethodChannels.ANDROID_PERMISSIONS] in a follow-up task.
*
* Trace: SDD-106 §5.
*/
var stateChangeListener: ((permission: String, state: PermissionState) -> Unit)? = null
fun ensureStartupPermissions(
activity: Activity,
callback: (Map<String, PermissionState>) -> Unit,
) {
val required = mutableListOf<String>()
val resolved = mutableMapOf<String, PermissionState>()
fun resolveNow(permission: String) {
resolved[permission] = currentState(activity, permission)
}
val startupPermissions = buildList {
add(Manifest.permission.RECORD_AUDIO)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_CONNECT)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
}
for (permission in startupPermissions) {
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
resolveNow(permission)
} else {
markRequested(activity, permission)
required.add(permission)
}
}
if (required.isEmpty()) {
for (permission in startupPermissions) {
emit(permission, resolved[permission] ?: currentState(activity, permission), null)
}
callback(
startupPermissions.associateWith { resolved[it] ?: currentState(activity, it) }
)
return
}
pendingStartupCallback = callback
ActivityCompat.requestPermissions(
activity,
required.toTypedArray(),
REQ_STARTUP_PERMISSIONS,
)
}
/**
* Ensure `RECORD_AUDIO` is granted, prompting the user if not.
*
* Behaviour matrix (SDD-106 §1–§3):
* - Already granted → [callback] invoked synchronously with
* [PermissionState.Granted].
* - Not granted, may prompt → system dialog shown; result
* delivered asynchronously via
* [handleRequestPermissionsResult].
* - Permanently denied → caller is expected to surface
* "Open settings" affordance and call [openAppSettings].
*
* Trace: SDD-106 §1, §2, §3.
*/
fun ensureRecordAudioPermission(
activity: Activity,
callback: (PermissionState) -> Unit,
) {
val permission = Manifest.permission.RECORD_AUDIO
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
emit(permission, PermissionState.Granted, callback)
return
}
// Stash the callback; result is dispatched in
// handleRequestPermissionsResult.
pendingCallback = callback
// SDD-106 §3, §4 (M-1 strict-review fix): record that the user
// has now been prompted at least once. This persists across
// process death so subsequent shouldShowRequestPermissionRationale
// == false readings can be classified as permanent denial rather
// than "never asked".
markRecordAudioRequested(activity)
ActivityCompat.requestPermissions(
activity,
arrayOf(permission),
REQ_RECORD_AUDIO,
)
}
/**
* Forwarded from `Activity.onRequestPermissionsResult`. Returns
* `true` if the result was consumed by this requester, `false`
* otherwise (so the caller can chain other requesters).
*
* Trace: SDD-106 §2, §3.
*/
fun handleRequestPermissionsResult(
activity: Activity,
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
): Boolean {
if (requestCode == REQ_STARTUP_PERMISSIONS) {
val cb = pendingStartupCallback
pendingStartupCallback = null
val resolved = mutableMapOf<String, PermissionState>()
permissions.forEachIndexed { index, permission ->
val state = if (index < grantResults.size &&
grantResults[index] == PackageManager.PERMISSION_GRANTED
) {
PermissionState.Granted
} else {
currentDeniedState(activity, permission)
}
resolved[permission] = state
emit(permission, state, null)
}
cb?.invoke(resolved)
return true
}
if (requestCode != REQ_RECORD_AUDIO) return false
val cb = pendingCallback
pendingCallback = null
val idx = permissions.indexOf(Manifest.permission.RECORD_AUDIO)
if (idx < 0 || idx >= grantResults.size) {
// Edge case: user dismissed dialog without a result (e.g.
// tap outside on some OEMs). Treat as Denied (re-promptable).
Log.w(TAG, "RECORD_AUDIO result missing from callback; treating as Denied")
emit(Manifest.permission.RECORD_AUDIO, PermissionState.Denied, cb)
return true
}
val state: PermissionState = if (grantResults[idx] == PackageManager.PERMISSION_GRANTED) {
PermissionState.Granted
} else {
// Per SDD-106 §3 (M-1 strict-review fix): distinguish
// "permanently denied" via shouldShowRequestPermissionRationale
// == false AFTER a denial. Because we just returned from a
// system dialog, the persistent has-ever-requested flag is
// guaranteed true at this point; we still consult it for
// symmetry with onResume and to make the contract explicit.
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.RECORD_AUDIO,
)
val hasEverRequested = hasEverRequestedRecordAudio(activity)
if (shouldRationale) {
PermissionState.Denied
} else if (hasEverRequested) {
PermissionState.PermanentlyDenied
} else {
// Defensive: should be unreachable because we set the
// flag immediately before requestPermissions, but if a
// host bypasses ensureRecordAudioPermission and forwards
// a result, treat the absence of prior ask as Denied
// rather than over-classifying as permanent.
PermissionState.Denied
}
}
emit(Manifest.permission.RECORD_AUDIO, state, cb)
return true
}
/**
* Re-check `RECORD_AUDIO` state on Activity resume.
*
* Contract: [MainActivity] (Wave 2B-4) MUST invoke this from its
* `onResume` so mid-session revocation (SDD-106 §4) — which Android
* may apply by killing/restarting the process — is observed and
* propagated to the Rust audio engine via [stateChangeListener].
*
* The active voice session (per SDD-094) is not torn down here;
* only the cached permission state is refreshed. Clamping
* `capture_active = false` in the listen-only path is the Rust
* audio engine's responsibility (SDD-106 §4, §6).
*
* Trace: SDD-106 §4.
*/
fun onResume(activity: Activity, callback: (PermissionState) -> Unit) {
val permission = Manifest.permission.RECORD_AUDIO
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
val state: PermissionState = if (granted) {
PermissionState.Granted
} else {
// SDD-106 §3, §4 (M-1 strict-review fix): distinguish three
// cases that all present as "not granted" on cold launch:
//
// (a) Permission was never asked in any prior process —
// persistent flag is false. Surface Denied (re-promptable);
// UI will trigger the system dialog on first use.
// (b) Permission was asked before and is currently denied
// but still re-promptable — shouldShowRequestPermissionRationale
// returns true. Surface Denied.
// (c) Permission was asked before and the user selected
// "Don't ask again" or revoked from Settings — flag is
// true AND shouldShowRequestPermissionRationale is false.
// Surface PermanentlyDenied so the UI can deep-link to
// app settings (SDD-106 §3).
//
// Without the persistent flag, cases (a) and (c) are
// indistinguishable after a process restart, which is what
// the original implementation conservatively folded into
// Denied at the cost of misreporting revocation. The
// SharedPreferences-backed flag closes that gap.
val hasEverRequested = hasEverRequestedRecordAudio(activity)
if (!hasEverRequested) {
PermissionState.Denied
} else {
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.RECORD_AUDIO,
)
if (shouldRationale) PermissionState.Denied else PermissionState.PermanentlyDenied
}
}
emit(permission, state, callback)
}
fun currentState(activity: Activity, permission: String): PermissionState {
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
if (granted) return PermissionState.Granted
return currentDeniedState(activity, permission)
}
/**
* Deep-link to the application's Settings → App info page so the
* user can re-grant a permanently-denied permission.
*
* Trace: SDD-106 §3.
*/
fun openAppSettings(activity: Activity) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", activity.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
activity.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
// Surface for diagnostics; we do not silently swallow.
Log.e(TAG, "Failed to launch app settings: ${e.message}", e)
}
}
/** Emit a resolved state to both the per-request callback and the listener. */
private fun emit(
permission: String,
state: PermissionState,
callback: ((PermissionState) -> Unit)?,
) {
callback?.invoke(state)
// The stateChangeListener is wired by MainActivity to invoke
// METHOD_PERMISSION_STATE_CHANGED on ANDROID_PERMISSIONS, which
// publishes BridgeEvent::PermissionState to the bridge event
// stream. See MainActivity.configureFlutterEngine (SDD-106 §5).
stateChangeListener?.invoke(permission, state)
}
/**
* Read the persistent "has the user ever been asked for RECORD_AUDIO?"
* flag. See [KEY_RECORD_AUDIO_HAS_REQUESTED] for the contract.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private fun hasEverRequestedRecordAudio(activity: Activity): Boolean {
val prefs = activity.applicationContext.getSharedPreferences(
PREFS_FILE,
Context.MODE_PRIVATE,
)
return prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)
}
private fun hasEverRequested(activity: Activity, permission: String): Boolean {
val prefs = activity.applicationContext.getSharedPreferences(
PREFS_FILE,
Context.MODE_PRIVATE,
)
return prefs.getBoolean(requestedKey(permission), false)
}
/**
* Persist that the user has now been prompted for RECORD_AUDIO at
* least once. Idempotent. Uses `apply` (async, lossless across
* process death once committed) since the flag is consulted on
* subsequent launches, not in the same critical section.
*
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
*/
private fun markRecordAudioRequested(activity: Activity) {
val prefs = activity.applicationContext.getSharedPreferences(
PREFS_FILE,
Context.MODE_PRIVATE,
)
if (!prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)) {
prefs.edit().putBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, true).apply()
}
}
private fun markRequested(activity: Activity, permission: String) {
val prefs = activity.applicationContext.getSharedPreferences(
PREFS_FILE,
Context.MODE_PRIVATE,
)
val key = requestedKey(permission)
if (!prefs.getBoolean(key, false)) {
prefs.edit().putBoolean(key, true).apply()
}
if (permission == Manifest.permission.RECORD_AUDIO) {
markRecordAudioRequested(activity)
}
}
private fun requestedKey(permission: String): String = when (permission) {
Manifest.permission.RECORD_AUDIO -> KEY_RECORD_AUDIO_HAS_REQUESTED
Manifest.permission.BLUETOOTH_CONNECT -> KEY_BLUETOOTH_CONNECT_HAS_REQUESTED
Manifest.permission.POST_NOTIFICATIONS -> KEY_POST_NOTIFICATIONS_HAS_REQUESTED
else -> "requested_${permission.replace('.', '_')}"
}
private fun currentDeniedState(activity: Activity, permission: String): PermissionState {
val hasEverRequested = hasEverRequested(activity, permission)
if (!hasEverRequested) return PermissionState.Denied
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
activity,
permission,
)
return if (shouldRationale) {
PermissionState.Denied
} else {
PermissionState.PermanentlyDenied
}
}
}
@@ -0,0 +1,342 @@
package app.chanora.chanora_flutter
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Android foreground service that hosts the lifecycle of an active
* Chanora voice session.
*
* Trace:
* - SDD-107 `AndroidVoiceForegroundService` (foreground service class,
* notification channel id `chanora.voice.session`,
* `foregroundServiceType=microphone` on API 30+, POST_NOTIFICATIONS
* on API 33+, START_NOT_STICKY, ongoing-notification re-post on
* dismissal, lifecycle bound to voice_join / voice_leave /
* shutdown_if_idle).
* - SDD-105 (`AndroidJniBootstrap` — `JavaVM*` capture; the Rust side
* invokes [start] / [stop] via JNI per SDD-107 §10).
* - SDD-106 (`AndroidPermissionRequester` — service may start in
* listen-only mode; type=microphone is still declared so capture can
* resume on grant without a service restart, per SDD-107 §8).
* - SDD-108 (`AndroidAudioModeController` — owns
* `AudioManager.setMode`; this service does NOT touch the audio
* mode, per SDD-107 §8 and SDD-108 §1).
* - SAD-086 (source SAD).
*
* ## Naming
*
* The SDD-107 implementation text references the simple name
* `ChanoraVoiceForegroundService` in a `voice/` subpackage. This file
* uses the FQN `app.chanora.chanora_flutter.AndroidVoiceForegroundService`
* as coordinated for Wave 2B (the AndroidManifest entry declared by
* Wave 2B-1 matches this FQN). The behaviour and lifecycle contract
* are unchanged.
*
* ## Responsibilities
*
* This service is purely the foreground-lifecycle host: it keeps the
* process foregrounded so the Rust audio engine (`crates/chanora_audio`)
* can keep capture / playback streams open while the UI is backgrounded.
*
* It does NOT:
* - call `AudioManager.setMode` (owned by SDD-108 /
* `AndroidAudioModeController`),
* - open or drive audio streams (owned by the Rust audio engine
* via existing JNI on `crates/chanora_audio`),
* - perform any networking.
*/
class AndroidVoiceForegroundService : Service() {
companion object {
private const val TAG = "ChanoraVoiceFGS"
/**
* Notification channel id.
*
* Trace: SDD-107 §4.
*/
private const val CHANNEL_ID = "chanora.voice.session"
// SDD-107 §4: notification strings are sourced from
// res/values/strings.xml and accessed via the service context
// in instance methods below (ensureChannel, buildNotification).
/**
* Stable notification id ("CHAN") per SDD-107 §5. Must remain
* stable so that re-posts after user dismissal land on the
* same notification slot.
*/
private const val NOTIFICATION_ID = 0x4348414E
/** Intent action: begin / refresh the foreground session. */
const val ACTION_START_VOICE_SESSION: String =
"app.chanora.action.START_VOICE_SESSION"
/** Intent action: terminate the foreground session. */
const val ACTION_STOP_VOICE_SESSION: String =
"app.chanora.action.STOP_VOICE_SESSION"
/**
* Start the service in voice-session mode.
*
* Intended call sites:
* - Kotlin: [MainActivity] or platform glue.
* - Rust: invoked from the `voice_join` bridge handler via
* JNI per SDD-107 §10 (the `JavaVM*` captured by SDD-105
* is used to call this static method).
*
* Trace: SDD-107 §7 (lifecycle — start).
*/
@JvmStatic
fun start(context: Context) {
val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply {
action = ACTION_START_VOICE_SESSION
}
ContextCompat.startForegroundService(context, intent)
}
/**
* Stop the service. Idempotent per SDD-107 §7.
*
* Trace: SDD-107 §7 (lifecycle — stop).
*/
@JvmStatic
fun stop(context: Context) {
val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply {
action = ACTION_STOP_VOICE_SESSION
}
// We deliberately route through startService so the running
// service receives ACTION_STOP_VOICE_SESSION via
// onStartCommand and can perform an orderly stopForeground +
// stopSelf. If the service is already stopped this is a
// no-op aside from a brief onCreate/onDestroy cycle, which
// satisfies the idempotent-stop contract.
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// Background-start restrictions: if the app is in a
// state that disallows starting services (e.g.,
// process being torn down), there is nothing left to
// stop. Log and continue.
Log.w(TAG, "stop() could not deliver intent: ${e.message}")
}
}
}
/**
* Service is start-only — no clients bind. Per SDD-107 §1.
*
* Trace: SDD-107 §1.
*/
override fun onBind(intent: Intent?): IBinder? = null
/**
* Lifecycle entry. Promotes the service to foreground within the
* 5-second platform deadline per SDD-107 §5, dispatches on the
* incoming action, and returns [START_NOT_STICKY] per SDD-107 §7
* (process death must NOT auto-restart; state is rebuilt from the
* audio engine on the next `voice_join`).
*
* Trace: SDD-107 §5, §7.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP_VOICE_SESSION -> {
stopForegroundCompat()
stopSelf()
}
// Treat null action (e.g., service re-creation by the
// system before we return START_NOT_STICKY takes effect)
// and any unknown action the same as START — we must call
// startForeground within 5 seconds of onStartCommand or the
// platform will kill us with a ForegroundServiceDidNotStart
// exception (Android 12+).
ACTION_START_VOICE_SESSION, null -> promoteToForeground()
else -> {
Log.w(TAG, "Unknown action: ${intent.action}; treating as START")
promoteToForeground()
}
}
// SDD-107 §7: do not auto-restart on process death; the next
// voice_join re-starts the service explicitly.
return START_NOT_STICKY
}
/**
* Tear-down. Removes the ongoing notification and clears the
* channel slot per SDD-107 §7 (stop path).
*
* If the user manually dismissed the notification while the
* service was still running (API 34+ allows this per SDD-107 §7),
* the dismissal does NOT stop the session — the audio engine is
* the sole authority for when the service goes away. The service
* re-posts the notification on the next state transition; in
* practice "next state transition" means the next [start] call
* arriving with ACTION_START_VOICE_SESSION, which calls
* [startForeground] again. We do NOT schedule a JobScheduler /
* AlarmManager re-post (per the task scope) — re-posting is
* driven by the Rust audio engine emitting a state tick.
*
* Trace: SDD-107 §7.
*/
override fun onDestroy() {
stopForegroundCompat()
try {
val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager
nm?.cancel(NOTIFICATION_ID)
} catch (e: SecurityException) {
// Unlikely on cancel(), but POST_NOTIFICATIONS-related
// SecurityException surfaces have been reported on some
// OEM builds. Do not crash teardown.
Log.w(TAG, "cancel() raised SecurityException: ${e.message}")
}
super.onDestroy()
}
/**
* Build (or refresh) the channel and call [startForeground].
*
* Trace: SDD-107 §4 (channel), §5 (notification), §6
* (POST_NOTIFICATIONS handling).
*/
private fun promoteToForeground() {
ensureChannel()
val notification = buildNotification()
// POST_NOTIFICATIONS (API 33+): per SDD-107 §6, denial must NOT
// block the service. We still call startForeground; the
// platform will silently suppress the notification if the
// permission is missing. We log a warning so this case is
// observable.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
Log.w(
TAG,
"POST_NOTIFICATIONS not granted; service will run without a " +
"visible notification (SDD-107 §6).",
)
}
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Typed overload required on API 29+ when the manifest
// declares foregroundServiceType=microphone (SDD-107
// §2). On API 34+ this is enforced; declaring it on
// 29+ is forward-safe.
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
// API 28 (minSdk per build.gradle.kts): un-typed
// startForeground is the only available overload.
@Suppress("DEPRECATION")
startForeground(NOTIFICATION_ID, notification)
}
} catch (e: SecurityException) {
// FOREGROUND_SERVICE_MICROPHONE missing on API 34+, or
// RECORD_AUDIO missing while type=microphone is declared.
// Per SDD-107 §6 / §7 we do not crash; the audio engine
// will observe the absence via the BridgeEvent stream and
// clamp to listen-only.
Log.e(
TAG,
"startForeground(type=MICROPHONE) failed: ${e.message}. " +
"Service may not have promoted; SDD-107 §6 listen-only path applies.",
e,
)
}
}
/**
* Create the notification channel on first use. Repeated creates
* are no-ops per Android contract (SDD-107 §4).
*/
private fun ensureChannel() {
// minSdk = 28 (Android 9), so NotificationChannel APIs (O+) are
// unconditionally available.
val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager ?: run {
Log.e(TAG, "NotificationManager unavailable; cannot create channel")
return
}
val existing = nm.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.voice_session_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.voice_session_channel_description)
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
/**
* Build the ongoing notification.
*
* Privacy: per SDD-107 §5, the notification carries ONLY product
* copy — no server-supplied channel names, user names, or message
* content.
*
* Trace: SDD-107 §5.
*/
private fun buildNotification(): Notification {
val contentIntent: PendingIntent? = packageManager
.getLaunchIntentForPackage(packageName)
?.let { launch ->
PendingIntent.getActivity(
this,
0,
launch,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.voice_session_notification_title))
.setContentText(getString(R.string.voice_session_notification_text))
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setShowWhen(false)
if (contentIntent != null) {
builder.setContentIntent(contentIntent)
}
return builder.build()
}
/**
* Version-portable `stopForeground` that removes the notification.
*/
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
}
@@ -0,0 +1,174 @@
package app.chanora.chanora_flutter
import android.os.Build
import android.util.Log
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* BackIntentBridge — Kotlin half of SDD-028 (`BackIntentService`).
*
* Trace: SDD-028 (Android back-intent registration paths + deterministic
* route-pop ordering) / SAD-018.
*
* Responsibility (Kotlin side):
* * On API 33+ (`Build.VERSION.SDK_INT >= TIRAMISU`) register an
* `OnBackInvokedCallback` against `activity.onBackInvokedDispatcher`
* at `PRIORITY_DEFAULT`.
* * On API < 33, register an `OnBackPressedCallback` (enabled = true)
* against `activity.onBackPressedDispatcher`.
* * Both callbacks consume the system back event (no super / no
* re-dispatch) and forward a single `backIntent` MethodChannel call
* to Dart with payload `{"kind": "system_back"}`. The Dart side
* (`BackIntentService`) owns the deterministic route-pop policy
* (PTT-active → ignore, modal → close, non-root → pop, root →
* `exitCandidate`).
* * When Dart concludes the event is unhandled at the root route, it
* calls back via `popToSystem`, which invokes `activity.finish()`
* exactly once (M-2 strict-review fix: guarded by `isFinishing` /
* `isDestroyed` so a duplicate Dart-side ExitApp decision cannot
* re-enter `finish()`).
*
* Threading: all callbacks are dispatched on the Android main thread,
* matching SDD-028 §4 ("dispatch runs on the platform main thread").
*
* ## Lifecycle / ownership (M-3 strict-review fix)
*
* Previously this type was a Kotlin `object` (process-wide singleton)
* that retained a strong reference to a `FlutterActivity`. Even though
* [detach] cleared the reference, the singleton pattern is an
* Activity-leak footgun: any caller forgetting `detach()` would pin
* the Activity for the lifetime of the process.
*
* The bridge is now a plain `class`. `MainActivity` constructs one
* instance in `configureFlutterEngine`, holds it in a private field,
* and clears the field in `onDestroy` after calling [detach]. The
* Activity is therefore reachable only via the bridge instance, and
* the bridge instance is reachable only via `MainActivity` — when
* `MainActivity` is destroyed, both become eligible for collection.
*/
class BackIntentBridge {
private companion object {
private const val TAG = "BackIntentBridge"
private const val KEY_KIND = "kind"
private const val VALUE_SYSTEM_BACK = "system_back"
}
private var channel: MethodChannel? = null
private var attachedActivity: FlutterActivity? = null
// API 33+ path.
private var onBackInvokedCallback: OnBackInvokedCallback? = null
// Pre-33 path.
private var onBackPressedCallback: OnBackPressedCallback? = null
/**
* Attach the back-intent bridge to [activity] using [messenger] for
* the Dart `MethodChannel`. Idempotent: a second call detaches the
* prior attachment first.
*
* The [activity] reference is retained until [detach] is called.
* The owning `MainActivity` MUST invoke [detach] from its
* `onDestroy` (see class KDoc on lifecycle / ownership).
*/
fun attach(activity: FlutterActivity, messenger: BinaryMessenger) {
// Guard against double-attach (e.g. re-creation under config changes).
detach()
// X-2 strict-review fix: channel + method names sourced from the
// central MethodChannels registry rather than file-local literals.
val ch = MethodChannel(messenger, MethodChannels.BACK_INTENT)
channel = ch
attachedActivity = activity
// SDD-028: Dart -> Kotlin "popToSystem" closes the activity at root.
ch.setMethodCallHandler { call, result ->
when (call.method) {
MethodChannels.METHOD_POP_TO_SYSTEM -> {
// M-2 strict-review fix (SDD-028): guarantee exactly-once
// finish(). If Dart issues two ExitApp decisions in rapid
// succession we must not re-enter Activity teardown.
if (activity.isFinishing || activity.isDestroyed) {
Log.i(
TAG,
"popToSystem received but activity already finishing/destroyed; ignoring duplicate",
)
result.success(null)
return@setMethodCallHandler
}
activity.finish()
result.success(null)
}
else -> result.notImplemented()
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerApi33(activity, ch)
} else {
registerPre33(activity, ch)
}
}
/**
* Detach from the previously-attached activity and tear down all
* registrations. Safe to call repeatedly.
*/
fun detach() {
val activity = attachedActivity
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
onBackInvokedCallback?.let { cb ->
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(cb)
}
}
onBackInvokedCallback = null
onBackPressedCallback?.remove()
onBackPressedCallback = null
channel?.setMethodCallHandler(null)
channel = null
attachedActivity = null
}
private fun registerApi33(activity: FlutterActivity, channel: MethodChannel) {
val cb = OnBackInvokedCallback {
// SDD-028 §1: callback delegates to BackIntentService.dispatch()
// on the Dart side and does NOT call any system fallback.
channel.invokeMethod(
MethodChannels.METHOD_BACK_INTENT,
mapOf(KEY_KIND to VALUE_SYSTEM_BACK),
)
}
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
cb,
)
onBackInvokedCallback = cb
}
private fun registerPre33(activity: FlutterActivity, channel: MethodChannel) {
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// SDD-028 §1: forward to Dart; do NOT re-invoke the
// system fallback here. If Dart determines the event is
// unhandled (root route), it calls back via
// `popToSystem` which executes `activity.finish()`.
channel.invokeMethod(
MethodChannels.METHOD_BACK_INTENT,
mapOf(KEY_KIND to VALUE_SYSTEM_BACK),
)
}
}
// Cast resolves onBackPressedDispatcher via ComponentActivity (FlutterActivity → FragmentActivity → ComponentActivity).
(activity as ComponentActivity).onBackPressedDispatcher.addCallback(activity, cb)
onBackPressedCallback = cb
}
}
@@ -0,0 +1,62 @@
package app.chanora.chanora_flutter
import android.app.Application
import android.util.Log
/**
* Application subclass that owns the earliest-possible load of the
* `chanora_bridge` native cdylib.
*
* Trace: SDD-105 (AndroidJniBootstrap), DEC-004 (single JavaVM* capture point).
*
* Rationale (SDD-105):
* `JNI_OnLoad` in `crates/chanora_bridge/src/android_init.rs` captures the
* process-wide `JavaVM*` the first time the library is loaded. By performing
* the `System.loadLibrary("chanora_bridge")` call here in `Application.onCreate`
* we guarantee that the VM pointer is available *before* any Flutter plugin,
* background isolate, or FRB-generated stub attempts to call into Rust. This
* also ensures the load happens on the main thread, satisfying the threading
* guarantee documented in SDD-105.
*
* Manifest contract (Wave 2B-1):
* AndroidManifest.xml must reference this class via
* android:name="app.chanora.chanora_flutter.ChanoraApplication"
* in the <application> tag. Wave 2B-1 owns that edit; do not duplicate it
* here.
*/
class ChanoraApplication : Application() {
override fun onCreate() {
super.onCreate()
// SDD-105: load the native bridge as early as possible so JNI_OnLoad
// runs before any FRB call site is reached.
try {
// SDD-105 implementation detail: the Android NDK C++ runtime
// (libc++_shared.so) must be loaded BEFORE chanora_bridge so that
// chanora_bridge's undefined C++ symbols (notably
// __cxa_pure_virtual, __cxa_atexit) resolve via the global
// symbol namespace. libchanora_bridge.so does not carry a
// DT_NEEDED libc++_shared.so entry today (the Rust cdylib build
// does not emit one), so the loader will not auto-pull it just
// because it is co-located in jniLibs/<abi>/. This explicit
// ordered loadLibrary pair is the canonical NDK pattern.
// Follow-up: a cleaner build-side fix is to inject
// `-lc++_shared` into the Rust cdylib link args via
// cargo:rustc-link-lib in a build.rs, producing a DT_NEEDED
// entry that makes this manual ordering unnecessary.
System.loadLibrary("c++_shared")
System.loadLibrary("chanora_bridge")
} catch (t: UnsatisfiedLinkError) {
// SDD-105: panic-safe FFI boundary — log loudly, then rethrow so
// the process fails fast rather than silently running without the
// Rust core. A swallowed link error would manifest much later as
// a confusing UnsatisfiedLinkError on the first FRB call.
Log.e(TAG, "Failed to load native library", t)
throw t
}
}
companion object {
private const val TAG = "ChanoraApp"
}
}
@@ -1,5 +1,314 @@
package app.chanora.chanora_flutter package app.chanora.chanora_flutter
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import app.chanora.chanora_flutter.AndroidPermissionRequester
import app.chanora.chanora_flutter.BackIntentBridge
import app.chanora.chanora_flutter.MethodChannels
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() /**
* Host activity for the Chanora Flutter shell.
*
* Trace: SDD-105 (AndroidJniBootstrap), SDD-028 (Android lifecycle wiring),
* SDD-106 (AndroidPermissionRequester), DEC-004 (single JNI bootstrap path).
*
* Note (SDD-105):
* The `System.loadLibrary("chanora_bridge")` call previously lived in this
* activity's companion-object initialiser. It has been moved to
* [ChanoraApplication.onCreate] so the native library — and therefore
* `JNI_OnLoad`'s `JavaVM*` capture — is available before any plugin or
* background isolate touches the bridge. See ChanoraApplication.kt and
* the AndroidManifest <application android:name> declaration owned by
* Wave 2B-1.
*/
class MainActivity : FlutterActivity() {
companion object {
/**
* JNI entry point implemented in `chanora_bridge::android_init`.
* Initialises `ndk_context` with our Activity so the direct Oboe
* backend can find Android audio services when `chanora_audio`
* starts the capture / playback streams.
*
* Trace: SDD-105 (AndroidJniBootstrap). Signature must remain stable;
* the Rust side declares the matching `extern "system"` symbol.
*/
@JvmStatic
external fun initChanoraContext(context: Context)
/**
* JNI entry point implemented in `chanora_bridge::permission_jni`.
* Forwards a resolved Android runtime-permission state into the
* Rust bridge, which (a) clamps the audio engine's transmit
* selector when `permission == "android.permission.RECORD_AUDIO"`
* and the state is anything other than `Granted`, and (b)
* broadcasts a `BridgeEvent::PermissionState` so the Dart UI
* observes the authoritative state alongside the existing
* MethodChannel.
*
* The Rust side wraps the body in `catch_unwind` so a panic in
* the bridge never unwinds into the JVM.
*
* Trace: SDD-106 §5, §6; SRS-209. Signature must remain stable;
* the Rust side declares the matching `extern "system"` symbol
* `Java_app_chanora_chanora_1flutter_MainActivity_publishPermissionState`.
*/
@JvmStatic
external fun publishPermissionState(permission: String, state: String)
}
// SDD-106: Activity-bound permission requester. Nullable because it is
// only constructed once the FlutterEngine is configured; lifecycle
// callbacks (onResume / onRequestPermissionsResult) must null-guard.
private var permissionRequester: AndroidPermissionRequester? = null
// SDD-106: Retained so onResume / onDestroy can forward state changes
// to Dart on MethodChannels.ANDROID_PERMISSIONS.
private var permissionsChannel: MethodChannel? = null
private var audioOutputChannel: MethodChannel? = null
private var audioOutputEvents: EventChannel? = null
private var audioOutputController: AndroidAudioOutputController? = null
// SDD-111: Android audio lifecycle controller (route changes, device
// add/remove, app lifecycle). Mirrors the iOS
// `chanora/ios_audio_lifecycle` channel pattern.
private var audioLifecycleController: AndroidAudioLifecycleController? = null
// SDD-028 (M-3 strict-review fix): the back-intent bridge is owned
// by this Activity instance, not a process-wide `object`. Constructed
// in configureFlutterEngine and cleared in onDestroy after detach().
private var backIntentBridge: BackIntentBridge? = null
private fun permissionStateWireName(
state: AndroidPermissionRequester.PermissionState,
): String = when (state) {
AndroidPermissionRequester.PermissionState.Granted -> "Granted"
AndroidPermissionRequester.PermissionState.Denied -> "Denied"
AndroidPermissionRequester.PermissionState.PermanentlyDenied -> "PermanentlyDenied"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.navigationBarColor = Color.rgb(255, 251, 254)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
}
// SDD-105: pass the Application context to the Rust side so the
// audio engine can open device handles. Must run on the main thread
// before any `chanora_audio` call from Dart. The native library is
// already loaded by ChanoraApplication.onCreate at this point.
initChanoraContext(applicationContext)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// SDD-028 / DEC-004: attach the Android -> Dart back-intent bridge to
// the FlutterEngine's binary messenger as soon as the engine is
// available. The bridge forwards hardware-back and intent-back events
// into Dart's navigation stack.
//
// SDD-028 (M-3 strict-review fix): BackIntentBridge is now a class
// instance owned by this Activity rather than a process-wide `object`,
// eliminating the singleton-Activity-leak footgun.
val bridge = BackIntentBridge()
bridge.attach(this, flutterEngine.dartExecutor.binaryMessenger)
backIntentBridge = bridge
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
// Construct the permissions MethodChannel and the Activity-bound
// requester, then wire stateChangeListener to forward resolved
// PermissionState transitions to Dart.
val channel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MethodChannels.ANDROID_PERMISSIONS,
)
permissionsChannel = channel
val requester = AndroidPermissionRequester()
requester.stateChangeListener = { permission, state ->
val stateName = permissionStateWireName(state)
channel.invokeMethod(
MethodChannels.METHOD_PERMISSION_STATE_CHANGED,
mapOf(
"permission" to permission,
"state" to stateName,
),
)
// SDD-106 §5/§6: also forward the resolved state into the
// Rust bridge so `TransmitModeSelector` clamps the
// transmit gate authoritatively (independent of whether
// the Dart UI has re-rendered yet). The Rust side is
// panic-safe via `catch_unwind`; we still guard with
// try/catch here so a `UnsatisfiedLinkError` (e.g. an
// unexpected ABI mismatch) cannot crash MainActivity.
// Trace: SDD-106 §5, §6; SRS-209.
try {
publishPermissionState(permission, stateName)
} catch (t: Throwable) {
android.util.Log.w(
"Chanora",
"publishPermissionState JNI hook failed: ${t.message}",
t,
)
}
}
permissionRequester = requester
// SDD-106 §1, §3 (Dart-side integration follow-up): handle
// outbound Dart -> Kotlin calls so the Flutter UI can drive the
// runtime permission request and the settings deep-link. The
// resolved state is still delivered asynchronously via
// stateChangeListener -> METHOD_PERMISSION_STATE_CHANGED.
//
// Trace: SDD-106, SRS-209.
channel.setMethodCallHandler { call, result ->
when (call.method) {
"requestRecordAudio" -> {
val r = permissionRequester
if (r != null) {
r.ensureRecordAudioPermission(this) { state ->
result.success(permissionStateWireName(state))
}
} else {
result.error(
"no_requester",
"AndroidPermissionRequester not bound",
null,
)
}
}
"requestStartupPermissions" -> {
val r = permissionRequester
if (r != null) {
r.ensureStartupPermissions(this) { states ->
result.success(
states.mapValues { (_, state) -> permissionStateWireName(state) }
)
}
} else {
result.error(
"no_requester",
"AndroidPermissionRequester not bound",
null,
)
}
}
"openAppSettings" -> {
val r = permissionRequester
if (r != null) {
r.openAppSettings(this)
result.success(null)
} else {
result.error(
"no_requester",
"AndroidPermissionRequester not bound",
null,
)
}
}
else -> result.notImplemented()
}
}
val audioOutputController = AndroidAudioOutputController(applicationContext)
this.audioOutputController = audioOutputController
val audioOutputChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MethodChannels.AUDIO_OUTPUT,
)
this.audioOutputChannel = audioOutputChannel
audioOutputChannel.setMethodCallHandler(audioOutputController)
val audioOutputEvents = EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
MethodChannels.AUDIO_OUTPUT_EVENTS,
)
this.audioOutputEvents = audioOutputEvents
audioOutputEvents.setStreamHandler(audioOutputController)
// SDD-111: attach the Android audio lifecycle controller
// (route changes, device add/remove, interruptions).
val lifecycleController = AndroidAudioLifecycleController(applicationContext)
lifecycleController.attach(flutterEngine)
audioLifecycleController = lifecycleController
}
override fun onResume() {
super.onResume()
// SDD-106: re-evaluate Android runtime permission state on resume and
// forward the result to Dart via MethodChannel
// "app.chanora/android_permissions". Must be null-safe: if the
// requester is not yet wired we no-op rather than crashing.
//
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
val requester = permissionRequester
if (requester != null) {
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
requester.onResume(this) { _ -> }
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
requester.stateChangeListener?.invoke(
android.Manifest.permission.BLUETOOTH_CONNECT,
requester.currentState(this, android.Manifest.permission.BLUETOOTH_CONNECT),
)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
requester.stateChangeListener?.invoke(
android.Manifest.permission.POST_NOTIFICATIONS,
requester.currentState(this, android.Manifest.permission.POST_NOTIFICATIONS),
)
}
}
// SDD-111: re-evaluate the current audio route on resume.
audioLifecycleController?.onResume()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
// Forward the system result into the Activity-bound requester so its
// pending callback resolves and stateChangeListener fires.
permissionRequester?.handleRequestPermissionsResult(
this,
requestCode,
permissions,
grantResults,
)
}
override fun onDestroy() {
// SDD-028: detach the back-intent bridge before the activity is torn
// down so the FlutterEngine's binary messenger isn't retained.
//
// SDD-028 (M-3 strict-review fix): drop the owning reference so the
// bridge instance — and the Activity it retains — become eligible
// for collection immediately after onDestroy.
backIntentBridge?.detach()
backIntentBridge = null
// SDD-106: drop the Dart->Kotlin handler before nilling the
// channel so a late invokeMethod from Dart cannot land on a
// dangling requester reference.
permissionsChannel?.setMethodCallHandler(null)
audioOutputChannel?.setMethodCallHandler(null)
audioOutputEvents?.setStreamHandler(null)
audioOutputController?.detach()
// SDD-111: detach the audio lifecycle controller.
audioLifecycleController?.onDestroy()
audioLifecycleController = null
permissionRequester = null
permissionsChannel = null
audioOutputChannel = null
audioOutputEvents = null
audioOutputController = null
super.onDestroy()
}
}
@@ -0,0 +1,83 @@
package app.chanora.chanora_flutter
/**
* Central registry of Flutter MethodChannel names used by the Android
* platform code.
*
* Trace:
* - SDD-106 (`AndroidPermissionRequester` — bridge event surface)
* - SRS-209 (Android runtime permission UX)
*
* The channels declared here are the Kotlin-side contract only. The
* Dart-side handlers that subscribe / dispatch on these channels are
* deliberately out of scope for the Wave 2B-2 implementation slice and
* are handed off to a follow-up task.
*/
internal object MethodChannels {
/**
* Channel for Android runtime-permission state events emitted by
* [AndroidPermissionRequester]. The Kotlin side invokes
* [METHOD_PERMISSION_STATE_CHANGED] whenever the resolved permission
* state transitions.
*
* Trace: SDD-106 §5 (Bridge event surface), SRS-209.
*/
const val ANDROID_PERMISSIONS: String = "app.chanora/android_permissions"
/**
* Method name invoked on [ANDROID_PERMISSIONS] when the resolved
* permission state for a tracked Android runtime permission
* changes. Arguments are a `Map<String, Any>` with keys:
* - "permission": String (Android permission constant, e.g.
* "android.permission.RECORD_AUDIO")
* - "state": String (one of "Granted", "Denied",
* "PermanentlyDenied")
*
* Trace: SDD-106 §5.
*/
const val METHOD_PERMISSION_STATE_CHANGED: String = "permissionStateChanged"
/**
* Channel name for the Android back-intent bridge (Kotlin <-> Dart).
*
* The Dart side uses the matching constant `backIntentChannelName` in
* `lib/services/back_intent_service.dart`; both must remain in sync.
*
* Trace: SDD-028 (BackIntentService), SAD-018. X-2 strict-review fix:
* consolidated from BackIntentBridge.kt's previous private literal.
*/
const val BACK_INTENT: String = "app.chanora/back_intent"
/**
* Method invoked on [BACK_INTENT] from Kotlin -> Dart when a system
* back event fires. Payload: `{"kind": "system_back"}`.
*
* Trace: SDD-028 §1. X-2 strict-review fix.
*/
const val METHOD_BACK_INTENT: String = "backIntent"
/**
* Method invoked on [BACK_INTENT] from Dart -> Kotlin when the Dart
* side concludes the back event is unhandled at the root route and
* the activity should finish.
*
* Trace: SDD-028 §1. X-2 strict-review fix.
*/
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
/**
* Channel for Android audio lifecycle events (route changes,
* interruptions, app lifecycle). Mirrors the iOS
* `chanora/ios_audio_lifecycle` channel.
*
* Trace: SDD-111 (cross-platform mobile voice backend)
*/
const val ANDROID_AUDIO_LIFECYCLE: String = "chanora/android_audio_lifecycle"
const val AUDIO_OUTPUT: String = "app.audio_output"
const val AUDIO_OUTPUT_EVENTS: String = "app.audio_output/events"
const val METHOD_GET_OUTPUT_DEVICES: String = "getOutputDevices"
const val METHOD_GET_COMMUNICATION_DEVICES: String = "getCommunicationDevices"
const val METHOD_SET_COMMUNICATION_DEVICE: String = "setCommunicationDevice"
const val METHOD_CLEAR_COMMUNICATION_DEVICE: String = "clearCommunicationDevice"
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3C8.69,3 6,5.69 6,9V13C6,16.31 8.69,19 12,19C15.31,19 18,16.31 18,13V9C18,5.69 15.31,3 12,3ZM12,5C14.21,5 16,6.79 16,9V13C16,15.21 14.21,17 12,17C9.79,17 8,15.21 8,13V9C8,6.79 9.79,5 12,5ZM11,20V22H13V20H11Z" />
</vector>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_chanora_notification" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Chinese (Simplified) localized notification strings.
Product-owned copy; no server-supplied content per SDD-107 §5. -->
<string name="voice_session_channel_name">语音会话</string>
<string name="voice_session_channel_description">Chanora 语音会话处于活动状态时显示。</string>
<string name="voice_session_notification_title">Chanora — 语音会话进行中</string>
<string name="voice_session_notification_text">麦克风可能正在使用中。</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Voice session foreground service notification strings.
These are product-owned strings; server-supplied content is never
placed in notification copy per SDD-107 §5 privacy note. -->
<string name="voice_session_channel_name">Voice session</string>
<string name="voice_session_channel_description">Shown while a Chanora voice session is active.</string>
<string name="voice_session_notification_title">Chanora — Voice session active</string>
<string name="voice_session_notification_text">Microphone may be in use.</string>
</resources>
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false
} }
include(":app") include(":app")
Binary file not shown.
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>ad-hoc</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>teamID</key>
<string>ZNVDEVDRX3</string>
<key>provisioningProfiles</key>
<dict>
<key>app.teamspeak.chanora</key>
<string>Chanora_Ad_Hoc</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,9 @@
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
#include "Generated.xcconfig"
// Mirror Release.xcconfig (see explanation there). `-u` is the load-bearing
// flag: without it the linker drops Swift @_cdecl symbols (no Swift caller)
// before `-exported_symbol` can re-export them, and the verify_silero_exports
// build phase fails the build.
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
STRIP_STYLE = non-global
@@ -0,0 +1,27 @@
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"
#include "Generated.xcconfig"
// Force the linker to retain Swift @_cdecl symbols that the chanora_bridge
// Rust framework resolves at runtime via dlsym(RTLD_DEFAULT). Two flags per
// symbol, intentionally redundant:
//
// -u _sym marks the symbol as force-undefined at link time,
// which keeps the object that defines it from being
// dropped and prevents dead-strip from removing the
// definition. This is the load-bearing flag.
// -exported_symbol _sym re-exports the symbol in the final binary's
// dynamic symbol table so dlsym(RTLD_DEFAULT) can
// find it from the Rust framework at runtime.
//
// Without -u, Xcode Archive's -dead_strip (WMO + LTO) can remove the
// symbol before the export list is applied, and CoreML VAD silently falls
// back to WebRTC on TestFlight / App Store. The Swift-side static
// `unsafeBitCast` references in SileroCoreMLBridge.swift are belt-and-
// suspenders defense-in-depth, NOT the primary guarantee.
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
// `STRIP_STYLE = all` (Xcode default for archive installs) runs `strip` without
// `-x`, which removes even the global @_cdecl symbols the linker exported above
// via -exported_symbol. `non-global` runs `strip -x`, preserving globals so the
// Rust framework's dlsym(RTLD_DEFAULT) can find them. 264-byte cost in the app.
STRIP_STYLE = non-global
+53
View File
@@ -0,0 +1,53 @@
# Uncomment this line to define a global platform for your project
platform :ios, '16.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Chanora' do
use_frameworks!
# Chanora Rust bridge as a vendored framework. The podspec runs
# `cargo build --release --target aarch64-apple-ios` and wraps the
# produced dylib into chanora_bridge.framework. CocoaPods then
# integrates the framework into Runner.xcodeproj with the
# appropriate Embed & Sign build phase, so the Runner app ships
# with the bridge inside its Frameworks/ directory and
# flutter_rust_bridge can dlopen() it at runtime via FRB's
# default `chanora_bridge.framework/chanora_bridge` lookup path.
pod 'chanora_bridge', :path => '.'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+33
View File
@@ -0,0 +1,33 @@
PODS:
- chanora_bridge (1.0.0)
- Flutter (1.0.0)
- flutter_foreground_task (0.0.1):
- Flutter
- haptic_kit (1.0.0):
- Flutter
DEPENDENCIES:
- chanora_bridge (from `.`)
- Flutter (from `Flutter`)
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
EXTERNAL SOURCES:
chanora_bridge:
:path: "."
Flutter:
:path: Flutter
flutter_foreground_task:
:path: ".symlinks/plugins/flutter_foreground_task/ios"
haptic_kit:
:path: ".symlinks/plugins/haptic_kit/ios"
SPEC CHECKSUMS:
chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
PODFILE CHECKSUM: 85b93b53f958f1ff700a147e9da4374c8b1c6970
COCOAPODS: 1.16.2
@@ -10,11 +10,17 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; };
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */; };
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -41,22 +47,36 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.release.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146EE1CF9000F007C117D /* Chanora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Chanora.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.profile.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.profile.xcconfig"; sourceTree = "<group>"; };
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chanora.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chanora.debug.xcconfig"; path = "Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -64,6 +84,17 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
C8BACE02E6EE5F840EE3F174 /* Pods_Chanora.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
AF7C21841E770B5B95763515 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -78,9 +109,35 @@
path = RunnerTests; path = RunnerTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4351F25046559EFA4C03047A /* Frameworks */ = {
isa = PBXGroup;
children = (
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
DC4F9695FDCB1E0D04E08974 /* Pods_Chanora.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
8142F9547ECC31BB0C2830D3 /* Pods */ = {
isa = PBXGroup;
children = (
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */,
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */,
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */,
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
EAE6402BFC041304D1D0896D /* Pods-Chanora.debug.xcconfig */,
2EA1142FBFD36E2ED564A5AA /* Pods-Chanora.release.xcconfig */,
A23C02505CD7E5092CA7958C /* Pods-Chanora.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -96,13 +153,15 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
8142F9547ECC31BB0C2830D3 /* Pods */,
4351F25046559EFA4C03047A /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
97C146EF1CF9000F007C117D /* Products */ = { 97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
97C146EE1CF9000F007C117D /* Runner.app */, 97C146EE1CF9000F007C117D /* Chanora.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
); );
name = Products; name = Products;
@@ -119,7 +178,9 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -131,8 +192,10 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
4CE2B0164E685A90D0C8A958 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */, 331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */, 331C807F294A63A400263BE5 /* Resources */,
AF7C21841E770B5B95763515 /* Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -144,24 +207,31 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
97C146ED1CF9000F007C117D /* Runner */ = { 97C146ED1CF9000F007C117D /* Chanora */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */;
buildPhases = ( buildPhases = (
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
CA110001000000000000A100 /* Verify Silero Exports */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
6FBA233EFE37134BD02075C2 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
name = Runner; name = Chanora;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
8C5000032DD0000000000001 /* SileroCoreML */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Chanora.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -193,11 +263,15 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
97C146ED1CF9000F007C117D /* Runner */, 97C146ED1CF9000F007C117D /* Chanora */,
331C8080294A63A400263BE5 /* RunnerTests */, 331C8080294A63A400263BE5 /* RunnerTests */,
); );
}; };
@@ -219,6 +293,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -241,6 +316,67 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
4CE2B0164E685A90D0C8A958 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
6FBA233EFE37134BD02075C2 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chanora/Pods-Chanora-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Chanora-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -256,6 +392,21 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
}; };
CA110001000000000000A100 /* Verify Silero Exports */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Verify Silero Exports";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -274,6 +425,7 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -282,7 +434,7 @@
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = { 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */; target = 97C146ED1CF9000F007C117D /* Chanora */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
@@ -350,7 +502,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@@ -365,15 +517,24 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello; PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Chanora_iOS_Ad Hoc";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_iOS_Ad Hoc";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -382,48 +543,51 @@
}; };
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
}; };
name = Debug; name = Debug;
}; };
331C8089294A63A400263BE5 /* Release */ = { 331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
}; };
name = Release; name = Release;
}; };
331C808A294A63A400263BE5 /* Profile */ = { 331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Chanora.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Chanora";
}; };
name = Profile; name = Profile;
}; };
@@ -476,7 +640,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -527,7 +691,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@@ -544,15 +708,24 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello; PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = Chanora_ios_Development;
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Chanora_ios_Development;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -566,15 +739,24 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 101;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = ZNVDEVDRX3;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Chanora;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello; PRODUCT_BUNDLE_IDENTIFIER = app.teamspeak.chanora;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "Chanora_App Store";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Chanora_App Store";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -604,7 +786,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Chanora" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
97C147061CF9000F007C117D /* Debug */, 97C147061CF9000F007C117D /* Debug */,
@@ -615,6 +797,29 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "../../../silero-coreml";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
8C5000032DD0000000000001 /* SileroCoreML */ = {
isa = XCSwiftPackageProductDependency;
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
productName = SileroCoreML;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }
@@ -1,10 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1510" LastUpgradeVersion = "1510"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Chanora.app"
BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -15,8 +33,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
@@ -32,8 +50,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
@@ -68,8 +86,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
@@ -85,8 +103,8 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D" BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app" BuildableName = "Chanora.app"
BlueprintName = "Runner" BlueprintName = "Chanora"
ReferencedContainer = "container:Runner.xcodeproj"> ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
@@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>
@@ -0,0 +1,335 @@
import UIKit
import Flutter
import AVFoundation
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iosAudioLifecycleChannel: FlutterMethodChannel?
private var iosPlatformChannel: FlutterMethodChannel?
private var iosAudioSessionChannel: FlutterMethodChannel?
/// Tracks whether a voice channel is currently active.
///
/// The AVAudioSession is intentionally not configured for VoIP at
/// app launch that would interrupt other apps' audio (Spotify,
/// Apple Music, podcasts) the moment the user opens Chanora, even
/// when they're just reading chat. Production VoIP apps (Telegram
/// group calls, Signal, Discord, Element) only switch the session
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
/// a voice channel. See `docs/architecture/sad.md` and the
/// `chanora/ios_audio_session` MethodChannel contract.
///
/// This flag gates lifecycle handlers (interruption-ended,
/// media-services-reset) so we only rebuild the VoIP session if a
/// call is actually in progress. When false, those handlers leave
/// the session in the inactive `.ambient` baseline.
private var voiceSessionActive: Bool = false
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
// the launch-time .playAndRecord setup):
//
// At launch we set the category to .ambient and leave the
// session INACTIVE matching the Telegram / Signal / Discord /
// Element / Jitsi pattern and Apple's guidance that "a VoIP
// app's audio session should not be active" while idle.
// Configuring .playAndRecord + .voiceChat at launch stops other
// apps' music (Spotify, Apple Music, podcasts) the moment the
// user opens Chanora, even when they are just reading text chat.
//
// VoIP configuration is engaged on voice-channel join via the
// `chanora/ios_audio_session` MethodChannel, driven from Dart
// before `voiceJoin` starts VoiceProcessingIO and again as an
// idempotent guard on the AudioStarted lifecycle.
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "launch-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
}
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMediaServicesReset(_:)),
name: AVAudioSession.mediaServicesWereResetNotification,
object: nil
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// Activate the VoIP audio session. Called from Dart via the
/// `chanora/ios_audio_session` channel before a voice channel join
/// starts VoiceProcessingIO. Configures
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
/// (Spotify, podcasts) can keep playing alongside the voice
/// channel matching the Telegram group-call UX. Idempotent:
/// repeated calls while already active are a no-op.
private func activateVoiceSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
voiceSessionActive = true
logAudioSessionState(context: "activateVoiceSession")
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog(
"chanora_flutter: voice session active: " +
"sampleRate=\(session.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
)
} catch {
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
}
}
/// Deactivate the VoIP audio session and return to the idle
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
/// (intentional leave, disconnect, or connection lost).
/// `.notifyOthersOnDeactivation` lets other audio apps know they
/// can resume best-effort: Apple Music / Podcasts resume
/// reliably, Spotify is not guaranteed.
private func deactivateVoiceSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
}
do {
try session.setCategory(.ambient, mode: .default)
} catch {
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
}
voiceSessionActive = false
logAudioSessionState(context: "deactivateVoiceSession")
}
/// Reads back the actual AVAudioSession state and logs it for
/// SDD-098 compliance. Called after both setCategory and setActive
/// to verify that the session accepted the requested configuration.
private func logAudioSessionState(context: String) {
let s = AVAudioSession.sharedInstance()
NSLog("chanora_flutter: [\(context)] category=\(s.category.rawValue) mode=\(s.mode.rawValue) options=\(s.categoryOptions.rawValue) route outputs=\(s.currentRoute.outputs.map { "\($0.portType.rawValue)" })")
if s.sampleRate != 48000.0 {
NSLog("chanora_flutter: WARNING: actual sample rate \(s.sampleRate) != requested 48000")
}
if s.ioBufferDuration > 0.025 {
NSLog("chanora_flutter: WARNING: IO buffer duration \(s.ioBufferDuration) > 25ms, may cause latency")
}
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
return
}
let routeDescription = AVAudioSession.sharedInstance().currentRoute
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
// P1: Send the detailed route class to Rust on every route change,
// not just device plug/unplug. This covers:
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
// - .override (speaker/earpiece toggle)
// - .categoryChange (session category changed)
// - .wakeFromSleep (device woke from sleep)
// - .routeConfigurationChange (BT HFP connect/disconnect)
// The Rust side uses the route class to recompute the processing
// policy (route_policy.rs) and reset AEC delay state if needed.
let routeClass = classifyAudioRoute(routeDescription)
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
}
@objc private func handleInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
switch type {
case .began:
NSLog("chanora_flutter: audio interruption began")
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionBegan", arguments: nil)
case .ended:
let shouldResume = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt)
.map { $0 & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 }
?? false
NSLog("chanora_flutter: audio interruption ended shouldResume=\(shouldResume)")
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionEnded", arguments: shouldResume)
@unknown default:
break
}
}
@objc private func handleMediaServicesReset(_ notification: Notification) {
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
if voiceSessionActive {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
logAudioSessionState(context: "mediaServicesWereReset-voip")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
}
} else {
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "mediaServicesWereReset-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
}
}
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
}
override func applicationWillResignActive(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
}
override func applicationDidEnterBackground(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
}
override func applicationWillEnterForeground(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
}
override func applicationWillTerminate(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
iosAudioLifecycleChannel = FlutterMethodChannel(
name: "chanora/ios_audio_lifecycle",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosPlatformChannel = FlutterMethodChannel(
name: "chanora/ios_platform",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosAudioSessionChannel = FlutterMethodChannel(
name: "chanora/ios_audio_session",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
guard let self = self else {
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
return
}
switch call.method {
case "activateVoiceSession":
self.activateVoiceSession()
result(nil)
case "deactivateVoiceSession":
self.deactivateVoiceSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
iosPlatformChannel?.setMethodCallHandler { call, result in
switch call.method {
case "getMicrophonePermissionState":
result(self.microphonePermissionStateString())
case "requestMicrophonePermission":
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
result(granted ? "Granted" : self.microphonePermissionStateString())
}
}
case "openAppSettings":
guard let url = URL(string: UIApplication.openSettingsURLString) else {
result(false)
return
}
UIApplication.shared.open(url, options: [:]) { opened in
result(opened)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
for output in route.outputs {
switch output.portType {
case .builtInReceiver:
return "Earpiece"
case .builtInSpeaker:
return "Speaker"
case .headphones, .usbAudio:
return "WiredHeadset"
case .bluetoothHFP:
return "BluetoothHfp"
case .bluetoothA2DP:
return "BluetoothA2dp"
default:
break
}
}
return "Unknown"
}
private func microphonePermissionStateString() -> String {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted:
return "Granted"
case .denied:
return "Denied"
case .undetermined:
return "NotDetermined"
@unknown default:
return "Unknown"
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Flutter Rust Bridge Hello</string> <string>Chanora</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>flutter_rust_bridge_hello</string> <string>Chanora</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -24,8 +24,18 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to your voice servers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Chanora needs microphone access so you can talk on your voice server.</string>
<key>NSUserNotificationsUsageDescription</key>
<string>Chanora sends you a notification when another user pokes you.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
@@ -49,6 +59,12 @@
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Privacy Manifest for Chanora.
Apple started enforcing this file at App Store submission in
May 2024 for iOS / iPadOS / visionOS / watchOS apps. Without
it, archive upload to App Store Connect is rejected.
Reference: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
-->
<!-- Whether the app collects data that has been linked to the
user's identity. Chanora never gathers third-party analytics,
user accounts, ad identifiers, or device identifiers. The
only "data" leaving the device is the voice + chat content
the user explicitly sends to a TeamSpeak server they have
chosen. Per Apple's definition that is "not collected by
the app" because the destination is user-selected, not us.
-->
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<!-- Microphone audio that is transmitted to the user's
chosen voice server while the user is connected and
unmuted. Apple's data-type taxonomy classifies this
as "Audio Data". The audio is not linked to the user
(no Apple ID, no IDFA tied to the stream) and not
used for tracking. -->
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeAudioData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<!-- Communications: the user is talking to other
people on their chosen server. -->
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<!-- The app does not track users across other apps + websites
owned by other companies. -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<!-- "Required reason" APIs Chanora uses. Apple maintains a list
of system APIs that need a declared reason because they
historically were abused for fingerprinting. Chanora uses
the file-timestamp APIs (via tokio file I/O for storing the
identity key + bookmark db + audio_meta + chanora.log) and
the user-defaults API (via Flutter's path_provider plugin
which queries NSUserDefaults to resolve Application Support
paths). Documented reasons below match Apple's published
allow-list. -->
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<!-- File-timestamp APIs used by tokio + rusqlite when
reading / writing identity.tskey, chanora.db, and
chanora.log. Reason `C617.1`: app-internal,
timestamps of files inside the app's container. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<!-- UserDefaults read indirectly via path_provider's
query for the app's documents directory. Reason
`CA92.1`: access user defaults to read information
only accessible to the app itself. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<!-- System boot time read by tracing-subscriber for
timestamping log records. Reason `35F9.1`: measure
elapsed time between events that occur within the
app. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<!-- Disk space queried by rusqlite when checking sqlite
page capacity. Reason `E174.1`: display disk space
to the user. Actually we don't display it; we just
read it. The next-closest reason is `85F4.1`:
ensure disk space available before writes. -->
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>85F4.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,232 @@
import CoreML
import Darwin
import Foundation
import SileroCoreML
private final class ChanoraSileroVadBox {
let vad: SileroVADRunner
init() throws {
let configuration = MLModelConfiguration()
vad = try SileroVADRunner(configuration: configuration)
}
}
private let chanoraSileroErrorLock = NSLock()
private var chanoraSileroLastError = ""
private func setChanoraSileroLastError(_ message: String) {
chanoraSileroErrorLock.lock()
chanoraSileroLastError = message
chanoraSileroErrorLock.unlock()
}
@_cdecl("chanora_silero_vad_create")
public func chanoraSileroVadCreate() -> UnsafeMutableRawPointer? {
do {
let box = try ChanoraSileroVadBox()
return Unmanaged.passRetained(box).toOpaque()
} catch {
setChanoraSileroLastError(String(describing: error))
return nil
}
}
@_cdecl("chanora_silero_vad_destroy")
public func chanoraSileroVadDestroy(_ handle: UnsafeMutableRawPointer?) {
guard let handle else { return }
Unmanaged<ChanoraSileroVadBox>.fromOpaque(handle).release()
}
@_cdecl("chanora_silero_vad_reset")
public func chanoraSileroVadReset(_ handle: UnsafeMutableRawPointer?) -> Int32 {
guard let handle else {
setChanoraSileroLastError("SileroVAD handle is null")
return -1
}
let box = Unmanaged<ChanoraSileroVadBox>.fromOpaque(handle).takeUnretainedValue()
box.vad.reset()
return 0
}
@_cdecl("chanora_silero_vad_process")
public func chanoraSileroVadProcess(
_ handle: UnsafeMutableRawPointer?,
_ samples: UnsafePointer<Float>?,
_ sampleCount: Int,
_ probabilityOut: UnsafeMutablePointer<Float>?
) -> Int32 {
guard let handle else {
setChanoraSileroLastError("SileroVAD handle is null")
return -1
}
guard let samples else {
setChanoraSileroLastError("SileroVAD samples pointer is null")
return -2
}
guard let probabilityOut else {
setChanoraSileroLastError("SileroVAD probability output pointer is null")
return -3
}
guard sampleCount == SileroVADRunner.chunkSize else {
setChanoraSileroLastError("SileroVAD expected \(SileroVADRunner.chunkSize) samples, got \(sampleCount)")
return -4
}
let box = Unmanaged<ChanoraSileroVadBox>.fromOpaque(handle).takeUnretainedValue()
do {
let chunk = Array(UnsafeBufferPointer(start: samples, count: sampleCount))
probabilityOut.pointee = try box.vad.process(chunk)
return 0
} catch {
setChanoraSileroLastError(String(describing: error))
return -5
}
}
@_cdecl("chanora_silero_vad_last_error")
public func chanoraSileroVadLastError() -> UnsafeMutablePointer<CChar>? {
chanoraSileroErrorLock.lock()
let message = chanoraSileroLastError
chanoraSileroErrorLock.unlock()
return strdup(message)
}
@_cdecl("chanora_silero_vad_free_string")
public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
guard let string else { return }
free(string)
}
@objc public final class ChanoraSileroSelfTest: NSObject {
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
// functions directly would mask it because direct calls bypass the dynamic symbol table.
@objc public static func run() {
let started = DispatchTime.now()
// Static linker references: keep the Swift compiler / linker from
// dead-stripping the @_cdecl symbols under Whole-Module-Optimization
// + LTO in Archive builds. dlsym(RTLD_DEFAULT) below does NOT count
// as a static reference for the dead-stripper these `_ = ` lines
// do. Without them, TestFlight builds shipped without the symbols
// even though Debug builds (no LTO) worked.
//
// The `withoutActuallyEscaping` dance prevents the optimizer from
// proving the references are unused: assigning the function value
// to a `@convention(c)` typealias forces address-taken semantics.
_ = unsafeBitCast(
chanoraSileroVadCreate as @convention(c) () -> UnsafeMutableRawPointer?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadDestroy as @convention(c) (UnsafeMutableRawPointer?) -> Void,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadReset as @convention(c) (UnsafeMutableRawPointer?) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadProcess
as @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int,
UnsafeMutablePointer<Float>?
) -> Int32,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadLastError as @convention(c) () -> UnsafeMutablePointer<CChar>?,
to: UnsafeRawPointer.self,
)
_ = unsafeBitCast(
chanoraSileroVadFreeString as @convention(c) (UnsafeMutablePointer<CChar>?) -> Void,
to: UnsafeRawPointer.self,
)
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ProcessFn = @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
) -> Int32
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
func resolve<T>(_ name: String, as type: T.Type) -> T? {
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
return nil
}
return unsafeBitCast(raw, to: type)
}
let names = [
"chanora_silero_vad_create",
"chanora_silero_vad_destroy",
"chanora_silero_vad_reset",
"chanora_silero_vad_process",
"chanora_silero_vad_last_error",
"chanora_silero_vad_free_string",
]
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
if !missing.isEmpty {
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
return
}
guard
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
return
}
func readError() -> String {
guard let ptr = lastError() else { return "unknown" }
let msg = String(cString: ptr)
freeString(ptr)
return msg
}
guard let handle = create() else {
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let resetRc = reset(handle)
if resetRc != 0 {
destroy(handle)
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let chunkSize = SileroVADRunner.chunkSize
var probability: Float = 0
let samples = [Float](repeating: 0, count: chunkSize)
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
process(handle, buf.baseAddress, chunkSize, &probability)
}
destroy(handle)
let elapsedMs = elapsedMs(since: started)
if processRc == 0 {
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
} else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
}
}
private static func elapsedMs(since start: DispatchTime) -> String {
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
return String(format: "%.1f", Double(ns) / 1_000_000.0)
}
}
@@ -0,0 +1,308 @@
#
# chanora_bridge — Rust bridge library as a CocoaPods-vended dynamic framework.
#
# What this file does:
#
# 1. `prepare_command` runs once per `pod install` and once per archive
# / device build. It:
# a) Invokes `cargo build --release --target <iOS arch>` for both
# aarch64-apple-ios (device) and aarch64-apple-ios-sim
# (simulator on Apple Silicon) so the resulting framework
# works for both targets.
# b) Wraps the built dylib in a proper `chanora_bridge.framework`
# bundle (Versions/A layout with the right Info.plist).
# c) Rewrites LC_ID_DYLIB to
# @rpath/chanora_bridge.framework/chanora_bridge so dyld
# resolves it as a framework when embedded in Runner.app.
#
# 2. `vendored_frameworks` exposes the result to the Pods workspace.
# CocoaPods then integrates the framework into Runner.xcodeproj
# with the appropriate embed-and-sign build phase.
#
# Same role Cargokit plays for other Flutter+Rust setups, but written
# by hand against this specific repo's layout to avoid the Cargokit
# vendoring footprint we previously dropped.
#
# Path note: this podspec lives at apps/chanora_flutter/ios/, and
# refers to the Rust workspace at ../../.. (repo root). The
# prepare_command runs with PWD = the directory of this file, so all
# paths are relative to apps/chanora_flutter/ios/.
Pod::Spec.new do |s|
s.name = 'chanora_bridge'
s.version = '1.0.0'
s.summary = 'Chanora Rust bridge (libchanora_bridge) as an iOS framework.'
s.description = <<-DESC
Vendors the Rust-built libchanora_bridge as chanora_bridge.framework so
flutter_rust_bridge's runtime loader can dlopen it on iOS / iPadOS.
DESC
s.homepage = 'https://github.com/EdisonJwa/chanora'
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
s.author = { 'EdisonJwa' => 'me@edison.network' }
s.source = { :path => '.' }
s.platform = :ios, '16.0'
# Build the Rust bridge on `pod install`. The script runs under
# bash; we use `set -e` so any failure (cargo missing, target not
# installed, link error) aborts the install loudly. The
# CMAKE_POLICY_VERSION_MINIMUM + IPHONEOS_DEPLOYMENT_TARGET
# env vars satisfy audiopus_sys's cmake invocation on modern
# CMake 4.x and Apple iOS SDK; both are required.
s.prepare_command = <<-SCRIPT
set -e
REPO_ROOT="$(cd ../../.. && pwd)"
USER_NAME="$(id -un)"
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
if [ -z "$USER_HOME" ]; then
USER_HOME="$(cd ~ && pwd)"
fi
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
find_cargo() {
for candidate in \
"$USER_HOME/.cargo/bin/cargo" \
"/opt/homebrew/opt/rustup/bin/cargo" \
"/usr/local/opt/rustup/bin/cargo"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v cargo
}
find_rustc() {
for candidate in \
"$USER_HOME/.cargo/bin/rustc" \
"/opt/homebrew/opt/rustup/bin/rustc" \
"/usr/local/opt/rustup/bin/rustc"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v rustc
}
CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
cd "$REPO_ROOT"
HOME="$USER_HOME" \\
CARGO_HOME="$USER_HOME/.cargo" \\
RUSTUP_HOME="$USER_HOME/.rustup" \\
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
RUSTC="$RUSTC_BIN" \\
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
if [ ! -f "$BRIDGE" ]; then
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
exit 1
fi
cd "$REPO_ROOT/apps/chanora_flutter/ios"
# Build the chanora_bridge.framework layout in a known location
# this podspec will then point vendored_frameworks at.
FW=Frameworks/chanora_bridge.framework
rm -rf "$FW"
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>chanora_bridge</string>
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
<key>CFBundleName</key><string>chanora_bridge</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
<key>MinimumOSVersion</key><string>16.0</string>
</dict>
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
# Generate the framework's dSYM bundle. Apple's archive validator
# rejects uploads when an embedded framework has no matching dSYM
# (UUID lookup miss in the archive's dSYMs/ folder), which is the
# failure mode that produced this prepare_command in the first
# place. dsymutil reads the DWARF that cargo emitted (enabled by
# [profile.release] debug = true at the workspace root) and writes
# chanora_bridge.framework.dSYM next to the framework. We then
# strip the in-framework binary so the shipped app stays slim —
# the symbols live exclusively in the dSYM bundle, which is the
# layout xcodebuild -exportArchive and App Store Connect expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into
# Runner.app/Frameworks/ with Embed & Sign automatically.
s.vendored_frameworks = 'Frameworks/chanora_bridge.framework'
# Re-run the cargo build + framework wrap on every Xcode build,
# not only on `pod install`. The `prepare_command` above runs
# once per `pod install` which is too sticky — Rust source
# changes were getting silently ignored because Xcode happily
# re-bundled the stale framework. This script_phase shells out to
# cargo on every Xcode "Build" so the framework is always in sync
# with the current Rust workspace.
#
# `:execution_position => :before_compile` runs the script before
# Xcode's CompileSources phase, so by the time the linker / embed
# step sees `Frameworks/chanora_bridge.framework`, it is fresh.
s.script_phase = {
:name => 'Rebuild chanora_bridge.framework from Rust',
:script => <<-SCRIPT,
set -e
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
USER_NAME="$(id -un)"
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
if [ -z "$USER_HOME" ]; then
USER_HOME="$(cd ~ && pwd)"
fi
find_cargo() {
for candidate in \
"$USER_HOME/.cargo/bin/cargo" \
"/opt/homebrew/opt/rustup/bin/cargo" \
"/usr/local/opt/rustup/bin/cargo"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v cargo
}
find_rustc() {
for candidate in \
"$USER_HOME/.cargo/bin/rustc" \
"/opt/homebrew/opt/rustup/bin/rustc" \
"/usr/local/opt/rustup/bin/rustc"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v rustc
}
CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
RUST_TARGET="aarch64-apple-ios-sim"
SUPPORTED_PLATFORM="iPhoneSimulator"
else
RUST_TARGET="aarch64-apple-ios"
SUPPORTED_PLATFORM="iPhoneOS"
fi
BRIDGE="$REPO_ROOT/target/$RUST_TARGET/release/libchanora_bridge.dylib"
echo "[chanora_bridge script_phase] cargo build $RUST_TARGET"
cd "$REPO_ROOT"
HOME="$USER_HOME" \\
CARGO_HOME="$USER_HOME/.cargo" \\
RUSTUP_HOME="$USER_HOME/.rustup" \\
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
RUSTC="$RUSTC_BIN" \\
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
CARGO_PROFILE_RELEASE_DEBUG=true \\
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \\
CARGO_PROFILE_RELEASE_STRIP=false \\
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/ios"
FW=Frameworks/chanora_bridge.framework
# Skip the wrap step if the framework's binary is already
# up-to-date with the cargo output (fast no-op on incremental
# builds where Rust didn't change). We still publish the dSYM
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
# have the symbols, even when the framework itself is cached.
FW_UP_TO_DATE=0
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date"
FW_UP_TO_DATE=1
fi
if [ "$FW_UP_TO_DATE" = 0 ]; then
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>chanora_bridge</string>
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
<key>CFBundleName</key><string>chanora_bridge</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>$SUPPORTED_PLATFORM</string></array>
<key>MinimumOSVersion</key><string>16.0</string>
</dict>
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
fi
# Publish the dSYM into Xcode's archive dSYM folder on every
# build (cached or not). Without this the archive validator
# fails with "archive did not include a dSYM for the
# chanora_bridge.framework with the UUIDs [<uuid>]" and the
# IPA cannot be uploaded to App Store Connect / TestFlight.
# ${DWARF_DSYM_FOLDER_PATH} resolves to <ARCHIVE>/dSYMs for
# archive builds and <BUILT_PRODUCTS_DIR> otherwise; both paths
# are the ones xcodebuild scans when collecting symbols.
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
fi
SCRIPT
:execution_position => :before_compile,
}
# The pod has no Objective-C sources; it's purely a framework
# carrier. Suppress CocoaPods's source-file warning.
s.source_files = 'Frameworks/chanora_bridge.framework/Headers/*.h'
# No public Swift / ObjC API to expose — the Flutter app talks to
# the framework via FRB's FFI symbol lookup at runtime, not via
# imports.
end
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
// Canonical layout breakpoints for Chanora.
//
// Aligned with Material 3 adaptive layout guidance:
// compact < 600dp — phone, narrow tablet
// medium 6001023 — tablet portrait, small desktop window
// expanded ≥ 1024dp — desktop, tablet landscape
//
// 1024dp was chosen as the expanded threshold based on production app
// research: Discord (member list at 1024px), Mattermost (RHS docked at
// ≥ 1024px), and Rocket.Chat (contextual bar persistent at lg/1024px).
/// Canonical breakpoint thresholds in logical pixels.
///
/// Use these instead of hardcoded pixel values in layout decisions.
/// Migrate existing `_wideBreakpoint` / `_chatMobileBreakpoint` references
/// to these named constants.
class ChanoraBreakpoints {
ChanoraBreakpoints._();
/// Width at which the layout switches from compact to medium.
/// Below this: single-column mobile layout.
/// At/above: two-panel side-by-side layout.
static const double medium = 600;
/// Width at which the layout switches from medium to expanded.
/// Below this: chat opens as a pushed route.
/// At/above: three-panel layout with inline chat panel.
static const double expanded = 1024;
// Panel sizing constants.
/// Fixed width of the left voice/control panel.
static const double voicePanelWidth = 320;
/// Fixed width of the right chat panel (expanded layout only).
static const double chatPanelWidth = 380;
/// Horizontal gap between panels.
static const double panelGap = 12;
/// Desktop snackbar width cap (used when width ≥ [medium]).
static const double snackBarDesktopCap = 560;
/// Connect form action buttons switch from row to column below this width.
static const double connectActionsStackMaxWidth = 400;
/// Modal bottom sheet max height as fraction of screen height.
static const double modalSheetHeightFraction = 0.72;
}
/// Semantic layout class derived from viewport width.
enum LayoutClass {
/// < 600dp — single-column mobile layout.
compact,
/// 6001023dp — two-panel side-by-side layout.
medium,
/// ≥ 1024dp — three-panel layout with inline chat.
expanded,
}
/// Computes the current [LayoutClass] from viewport [width].
LayoutClass layoutClassFromWidth(double width) {
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
return LayoutClass.compact;
}
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: Apache-2.0
// Chanora design tokens for connection, voice, and latency states.
// Maps abstract server/voice/network state to Material 3 colour, icon,
// and label affordances.
import 'package:flutter/material.dart';
/// Tokenised visual mapping for a connection state.
class ConnectionTokens {
final Color color;
final Color? background;
final IconData icon;
final String label;
const ConnectionTokens({
required this.color,
required this.icon,
required this.label,
this.background,
});
static ConnectionTokens disconnected(ColorScheme cs) => ConnectionTokens(
color: cs.onSurfaceVariant,
background: cs.surfaceContainerHighest,
icon: Icons.cloud_off,
label: 'Disconnected',
);
static ConnectionTokens connecting(ColorScheme cs) => ConnectionTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.sync,
label: 'Connecting',
);
static ConnectionTokens synchronizing(ColorScheme cs) => ConnectionTokens(
color: cs.secondary,
background: cs.secondaryContainer,
icon: Icons.hourglass_top,
label: 'Synchronizing',
);
static ConnectionTokens connected(ColorScheme cs) => ConnectionTokens(
color: cs.tertiary,
background: cs.tertiaryContainer,
icon: Icons.cloud_done,
label: 'Connected',
);
static ConnectionTokens reconnecting(ColorScheme cs) => ConnectionTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.restart_alt,
label: 'Reconnecting',
);
static ConnectionTokens lost(ColorScheme cs) => ConnectionTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.warning_amber,
label: 'Connection Lost',
);
}
/// Tokenised visual mapping for a voice transmit/mute state.
class VoiceTokens {
final Color color;
final Color? background;
final IconData icon;
const VoiceTokens({
required this.color,
required this.icon,
this.background,
});
static VoiceTokens idle(ColorScheme cs) => VoiceTokens(
color: cs.onSurfaceVariant,
icon: Icons.mic_none,
);
static VoiceTokens pttHeld(ColorScheme cs) => VoiceTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.mic,
);
static VoiceTokens voiceActivity(ColorScheme cs) => VoiceTokens(
color: cs.tertiary,
background: cs.tertiaryContainer,
icon: Icons.mic,
);
static VoiceTokens continuousTx(ColorScheme cs) => VoiceTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.settings_voice,
);
static VoiceTokens muted(ColorScheme cs) => VoiceTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.mic_off,
);
static VoiceTokens outputMuted(ColorScheme cs) => VoiceTokens(
color: cs.error,
icon: Icons.headset_off,
);
static VoiceTokens deafened(ColorScheme cs) => VoiceTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.hearing_disabled,
);
}
/// Tokenised visual mapping for network latency/quality.
class LatencyTokens {
final Color color;
final String label;
const LatencyTokens({required this.color, required this.label});
static LatencyTokens good(ColorScheme cs) => LatencyTokens(
color: cs.tertiary,
label: 'Good',
);
static LatencyTokens warning(ColorScheme cs) => LatencyTokens(
color: cs.error,
label: 'Warning',
);
static LatencyTokens unknown(ColorScheme cs) => LatencyTokens(
color: cs.outlineVariant,
label: '',
);
}
/// Tokens for channel membership status.
class ChannelTokens {
final IconData? icon;
final String? label;
const ChannelTokens({this.icon, this.label});
static ChannelTokens inChannel(ColorScheme cs) => ChannelTokens(
icon: Icons.headset_mic,
label: 'Connected',
);
static ChannelTokens joining(ColorScheme cs) => ChannelTokens(
icon: Icons.login,
label: 'Joining...',
);
static ChannelTokens notInChannel(ColorScheme cs) => ChannelTokens(
icon: null,
label: null,
);
}
/// Tokens for PTT capability display.
class PttTokens {
final String label;
final Color? color;
const PttTokens({required this.label, this.color});
static PttTokens focused(ColorScheme cs) => PttTokens(
label: 'Keyboard PTT',
color: cs.tertiary,
);
static PttTokens platformHook(ColorScheme cs) => PttTokens(
label: 'Global PTT',
color: cs.primary,
);
static PttTokens none(ColorScheme cs) => PttTokens(
label: 'No PTT input',
color: cs.error,
);
}
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: Apache-2.0
// SRS-115: Unsupported/degraded platform behaviours documentation.
// Describes what is available, degraded, or unavailable per platform.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
/// Describes capability tier for a feature on a platform.
enum CapabilityTier { supported, degraded, unavailable }
/// A single platform capability description.
class PlatformCapability {
final String feature;
final String description;
final CapabilityTier tier;
const PlatformCapability({
required this.feature,
required this.description,
required this.tier,
});
}
/// Returns the current platform's capability matrix.
List<PlatformCapability> currentPlatformCapabilities() {
if (kIsWeb) {
return [
PlatformCapability(
feature: 'Audio',
description: 'Web audio is not supported in the beta.',
tier: CapabilityTier.unavailable,
),
];
}
if (Platform.isAndroid) {
return _androidCapabilities();
}
if (Platform.isIOS) {
return _iosCapabilities();
}
if (Platform.isMacOS) {
return _macosCapabilities();
}
if (Platform.isWindows) {
return _windowsCapabilities();
}
if (Platform.isLinux) {
return _linuxCapabilities();
}
return [];
}
List<PlatformCapability> _androidCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'Oboe AAudio backend; Bluetooth SCO supported.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Foreground service',
description: 'Persistent notification during active connection.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on Android.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System-managed; wired/BT/SCO automatic routing.',
tier: CapabilityTier.degraded,
),
const PlatformCapability(
feature: 'Push-to-talk button',
description: 'Media button / volume-key binding supported.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _iosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'VoiceProcessingIO AudioUnit (default).',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'Platform AEC/NS/AGC via VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio route switching',
description:
'Handles speaker/earpiece/Bluetooth route changes and interruptions.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on iOS.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Background audio',
description: 'Supported via AVAudioSession background mode.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _macosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'cpal device enumeration; VoiceProcessingIO optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global key-binding API.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'macOS Keychain.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System audio output route picker.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _windowsCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'WASAPI via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2 with Silero VAD.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global hotkey binding.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Windows Credential Manager / DPAPI.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Installer',
description: 'MSIX packaging not yet available in beta.',
tier: CapabilityTier.unavailable,
),
];
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2 with Silero VAD; no platform VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via X11/Wayland global key-binding. ',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Secret Service / libsecret.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Desktop environment',
description: 'DE-specific behaviour: screen locker may suspend audio.',
tier: CapabilityTier.degraded,
),
];
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: Apache-2.0
// Viewport info inherited widget for Chanora.
//
// Computes [LayoutClass] once per frame from the current [MediaQuery] size
// and provides it to the entire widget subtree. Downstream widgets read
// `ViewportInfo.of(context)` instead of calling `LayoutBuilder` or
// `MediaQuery.sizeOf` directly for layout-class decisions.
import 'package:flutter/widgets.dart';
import 'breakpoints.dart';
/// Inherited widget that exposes the current layout class and viewport
/// dimensions to the entire subtree.
///
/// Insert this once near the top of the widget tree (inside the Scaffold
/// body or equivalent). All descendants can then read
/// `ViewportInfo.of(context)` to determine their layout behaviour.
class ViewportInfo extends InheritedWidget {
/// Creates a [ViewportInfo].
const ViewportInfo({
super.key,
required this.layoutClass,
required this.width,
required this.height,
required super.child,
});
/// Current layout class derived from viewport width.
final LayoutClass layoutClass;
/// Current viewport width in logical pixels.
final double width;
/// Current viewport height in logical pixels.
final double height;
/// Returns the nearest [ViewportInfo] in the widget tree.
///
/// Asserts that a [ViewportInfo] ancestor exists.
static ViewportInfo of(BuildContext context) {
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
assert(info != null, 'No ViewportInfo found in widget tree');
return info!;
}
/// Whether the current layout is compact (< 600dp).
bool get isCompact => layoutClass == LayoutClass.compact;
/// Whether the current layout is medium (6001023dp).
bool get isMedium => layoutClass == LayoutClass.medium;
/// Whether the current layout is expanded (≥ 1024dp).
bool get isExpanded => layoutClass == LayoutClass.expanded;
/// Whether the layout has room for at least two panels (medium or expanded).
bool get isWide => !isCompact;
@override
bool updateShouldNotify(ViewportInfo old) => layoutClass != old.layoutClass;
// NOTE: width/height changes within the same layout class do NOT trigger
// notification. Dependents who genuinely need pixel-level dimensions
// (rare — most layouts should switch on layoutClass) must use a local
// LayoutBuilder. Notifying on every pixel would rebuild every dependent
// on every resize frame, which is the exact pessimisation this
// InheritedWidget exists to avoid.
}
+282 -32
View File
@@ -1,65 +1,315 @@
{ {
"@@locale": "en", "@@locale": "en",
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Template ARB for English. Other locales must reference these keys. Server-provided content is NOT translated (ADR-008 / DEC-015).", "@@x-source-of-truth": "DEC-015 (register v0.9.5). Template ARB for English. Server-provided content is NOT translated (ADR-008 / DEC-015).",
"appTitle": "Chanora", "appTitle": "Chanora",
"@appTitle": {
"description": "Application title shown in launchers and the app bar. The product name `Chanora` is fixed by DEC-018 and must not be translated."
},
"homeNotProductionReadyBanner": "Alpha build — not production ready.", "homeNotProductionReadyBanner": "Beta build — voice in/out wired; not production ready.",
"@homeNotProductionReadyBanner": {
"description": "Plain-language banner informing testers that this build is not for end-user use. Mirrors README.md's status line."
},
"fieldServerHost": "Server address", "fieldServerHost": "Server address",
"@fieldServerHost": {
"description": "Label for the server-address input on the connect form."
},
"fieldNickname": "Nickname", "fieldNickname": "Nickname",
"@fieldNickname": { "fieldServerPassword": "Server password",
"description": "Label for the nickname input on the connect form." "fieldServerPasswordHelp": "Leave blank if the server does not require one.",
}, "fieldPassword": "Password",
"fieldDisplayName": "Display name",
"connectAction": "Connect", "connectAction": "Connect",
"@connectAction": { "disconnectAction": "Leave server",
"description": "Label for the button that initiates a connection." "disconnectConfirmTitle": "Leave server?",
}, "disconnectConfirmBody": "You will disconnect from the current server.",
"disconnectAction": "Disconnect", "disconnectConfirmCancel": "Stay",
"@disconnectAction": { "disconnectConfirmAction": "Leave",
"description": "Label for the button that ends the active connection."
},
"refreshAction": "Refresh", "refreshAction": "Refresh",
"@refreshAction": { "diagnosticsAction": "Diagnostics",
"description": "Label for the button that re-fetches the server snapshot." "diagnosticsSaveAction": "Save export",
"diagnosticsLiveUpdating": "Live updating",
"shareAction": "Share",
"diagnosticsSaved": "Diagnostic export saved to {path}",
"@diagnosticsSaved": {
"placeholders": { "path": { "type": "String" } }
}, },
"aboutAction": "About",
"aboutVersion": "Version {version}",
"@aboutVersion": {
"placeholders": { "version": { "type": "String" } }
},
"aboutAuthor": "Author: Edison Jwa",
"aboutNonAffiliation": "Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.",
"aboutLicenseHeading": "License",
"aboutLicenseBody": "Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.",
"aboutThirdPartyHeading": "Third-party software",
"aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.",
"copyAction": "Copy",
"closeAction": "Close",
"openAction": "Open",
"cancelAction": "Cancel",
"retryAction": "Retry",
"chatAction": "Chat",
"chatCloseAction": "Close chat",
"chatPanelCollapsedHint": "Tap the chat button to continue your conversation",
"chatNewPrivateAction": "New private chat",
"chatSearchClientsHint": "Search clients...",
"chatDirectMessageAction": "Private message",
"chatPokeAction": "Poke",
"clientInfoAction": "Info",
"startAudioAction": "Start audio",
"pttHoldToTalk": "Hold to talk",
"pttTransmitting": "Transmitting…",
"pttHoldToTalkSemanticsHint": "Press and hold to transmit voice; release to stop.",
"pttCapabilityBadge": "PTT: {level} ({backend})",
"@pttCapabilityBadge": {
"placeholders": {
"level": { "type": "String" },
"backend": { "type": "String" }
}
},
"pttCapabilityBoundKey": "Key: {key}",
"@pttCapabilityBoundKey": {
"placeholders": {
"key": { "type": "String" }
}
},
"pttCapabilityExplainTitle": "Push-to-Talk capability",
"pttCapabilityExplainFocusedHeading": "Focused PTT",
"pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.",
"pttCapabilityExplainGoGlobalWindows": "On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.",
"pttCapabilityExplainGoGlobalMacos": "On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.",
"pttCapabilityExplainGoGlobalLinux": "On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop's shortcut dialog when it appears.",
"pttCapabilityExplainGoGlobalIos": "On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.",
"pttCapabilityExplainGoGlobalGeneric": "Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.",
"pttConfigureAction": "Configure",
"pttConfigureTitle": "Configure Push-to-Talk binding",
"pttConfigurePrompt": "Press the key or mouse side button you want to use for Push-to-Talk.",
"pttConfigureWaiting": "(waiting for input…)",
"pttConfigureCaptured": "Captured",
"pttConfigurePrivacyNote": "Chanora never logs the actual key value. Only the input class (keyboard / mouse-side-button) and a platform-neutral label leave this dialog.",
"pttConfigureSaveAction": "Save",
"pttConfigurePortalRedirect": "Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.",
"inputMuteAction": "Mute mic",
"inputUnmuteAction": "Unmute mic",
"outputMuteAction": "Mute speaker",
"outputUnmuteAction": "Unmute speaker",
"joinChannelAction": "Join channel",
"leaveChannelAction": "Leave channel",
"channelPasswordTitle": "Channel password",
"bookmarksHeading": "Bookmarks",
"bookmarksEmpty": "No bookmarks yet. Enter a server above and tap \"Save bookmark\".",
"bookmarkAddAction": "Save bookmark",
"bookmarkAddTitle": "Save bookmark",
"bookmarkDeleteAction": "Delete bookmark",
"statusIdle": "Not connected", "statusIdle": "Not connected",
"@statusIdle": {},
"statusConnecting": "Connecting…", "statusConnecting": "Connecting…",
"@statusConnecting": {},
"statusConnected": "Connected to {server}", "statusConnected": "Connected to {server}",
"@statusConnected": { "@statusConnected": {
"placeholders": { "placeholders": { "server": { "type": "String" } }
"server": { "type": "String" }
}
}, },
"statusError": "Error: {message}", "statusError": "Error: {message}",
"@statusError": { "@statusError": {
"placeholders": { "message": { "type": "String" } }
},
"statusReconnecting": "Reconnecting… attempt {attempt}, in {delay}s",
"@statusReconnecting": {
"placeholders": { "placeholders": {
"message": { "type": "String" } "attempt": { "type": "int" },
"delay": { "type": "int" }
}
},
"statusConnectionLost": "Connection lost: {reason}",
"@statusConnectionLost": {
"placeholders": { "reason": { "type": "String" } }
},
"audioStatsLine": "TX {sent} frames • RX {received} frames • Mic {ptt}",
"@audioStatsLine": {
"placeholders": {
"sent": { "type": "int" },
"received": { "type": "int" },
"ptt": { "type": "String" }
} }
}, },
"channelsHeading": "Channels", "channelsHeading": "Channels",
"@channelsHeading": {},
"clientsHeading": "Online clients", "clientsHeading": "Online clients",
"@clientsHeading": {}, "serverWelcomeHeading": "Server welcome message",
"countChannelsAndClients": "{channels} channels • {clients} online", "countChannelsAndClients": "{channels} channels • {clients} online",
"@countChannelsAndClients": { "@countChannelsAndClients": {
"placeholders": { "placeholders": {
"channels": { "type": "int" }, "channels": { "type": "int" },
"clients": { "type": "int" } "clients": { "type": "int" }
} }
} },
"voiceModePtt": "PTT",
"voiceModeContinuous": "Continuous",
"voiceModeVoiceActivity": "Voice activity",
"voiceModeComingSoon": "Coming soon",
"voiceModeLabel": "Transmit mode",
"voiceReleaseTailLabel": "Release tail",
"voiceReleaseTailHint": "ms",
"voiceLeaveAction": "Leave voice",
"voiceHardMuteLabel": "Mute microphone",
"voiceOutputMuteLabel": "Mute speakers",
"voiceSettingsTitle": "Voice settings",
"voiceBindKeyAction": "Bind PTT key",
"voiceBoundKeyLabel": "Bound key",
"voicePttHoldHint": "Hold the button",
"voiceMicOn": "on",
"voiceMicOff": "off",
"voiceSheetTitle": "Voice",
"audioOutputLabel": "Audio output",
"audioRouteSpeaker": "Speaker",
"audioRouteReceiver": "iPhone receiver",
"audioRouteBluetooth": "Bluetooth",
"audioRouteWiredHeadset": "Wired headset",
"audioRouteCarAudio": "Car audio",
"audioRouteAirplay": "AirPlay",
"audioRouteUnknown": "Unknown",
"channelJoinAlreadyIn": "Already in this channel.",
"channelJoinFailedPermission": "Insufficient permission to join this channel.",
"channelJoinFailedPassword": "Wrong channel password.",
"channelJoinFailedFull": "Channel is full.",
"channelJoinFailedFamilyFull": "Channel family limit reached.",
"channelJoinFailedPrivate": "This channel is private.",
"channelJoinFailedTimeout": "Could not join channel: the server didn't respond in time.",
"channelJoinFailedFlooding": "Too many channel switches. Please wait a moment.",
"channelJoinFailedGeneric": "Could not join channel: {message}",
"@channelJoinFailedGeneric": {
"placeholders": {
"message": { "type": "String" }
}
},
"networkPermissionTitle": "Network Permission Required",
"networkPermissionBody": "Chanora needs permission to access the network. On macOS, go to System Settings → Privacy & Security → Local Network and enable Chanora, then try again.",
"networkPermissionOpenSettings": "Open System Settings",
"microphonePermissionTitle": "Microphone Permission Required",
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
"permissionGrantAction": "Grant",
"startupPermissionsTitle": "Permissions",
"startupPermissionsBody": "Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.",
"startupPermissionsNotNow": "Not now",
"startupPermissionsAllow": "Allow",
"audioRouteSystemDefault": "System default",
"audioRouteEarpiece": "Earpiece",
"audioRouteUsbHeadset": "USB headset",
"audioRouteOtherDevice": "Other device",
"audioRouteRefreshDevices": "Refresh audio devices",
"audioRouteCannotSelect": "This output cannot be selected.",
"audioRouteChangeFailed": "Could not change audio output.",
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
"iosAudioResuming": "Audio resuming",
"linkTrustTitle": "Open external link?",
"linkTrustBody": "You are about to open a link to:\n\n{domain}",
"@linkTrustBody": {
"placeholders": {
"domain": { "type": "String" }
}
},
"linkTrustRememberDomain": "Trust all links from this domain",
"clientInfoFetchingProfile": "Fetching TeamSpeak profile",
"clientInfoLoadingProfile": "Loading profile...",
"clientInfoProfileUnavailable": "Profile unavailable",
"clientInfoProfileUnavailableBody": "The server did not return profile details for this client.",
"clientInfoIdentitySection": "Identity",
"clientInfoMembershipSection": "Membership",
"clientInfoConnectionSection": "Connection",
"clientInfoHistorySection": "History",
"clientInfoTransferSection": "Transfer",
"clientInfoClientId": "Client ID",
"clientInfoDatabaseId": "Database ID",
"clientInfoUniqueId": "Unique ID",
"clientInfoDescription": "Description",
"clientInfoAvatar": "Avatar",
"clientInfoServerGroups": "Server groups",
"clientInfoChannelGroup": "Channel group",
"clientInfoChannelId": "Channel ID",
"clientInfoOnline": "Online",
"clientInfoIdle": "Idle",
"clientInfoPing": "Ping",
"clientInfoPingDeviation": "Ping deviation",
"clientInfoAddress": "Address",
"clientInfoPacketLossClientToServer": "Packet loss C->S",
"clientInfoPacketLossServerToClient": "Packet loss S->C",
"clientInfoFirstConnected": "First connected",
"clientInfoLastConnected": "Last connected",
"clientInfoConnections": "Connections",
"clientInfoDownloadedMonth": "Downloaded this month",
"clientInfoUploadedMonth": "Uploaded this month",
"clientInfoDownloadedTotal": "Downloaded total",
"clientInfoUploadedTotal": "Uploaded total",
"clientInfoUnknown": "Unknown",
"clientInfoHidden": "Hidden",
"clientInfoNone": "None",
"pokeSettingsAction": "Poke notifications",
"pokeSettingsTitle": "Poke notifications",
"pokeSettingsEnableLabel": "Notify me about pokes",
"pokeSettingsEnableDescription": "Show local notifications for incoming pokes when this is on.",
"pokeSettingsMutedSendersHeader": "Muted senders",
"pokeSettingsMutedSendersEmpty": "No muted poke senders.",
"pokeSettingsMutedSenderLabel": "Client ID {senderId}",
"@pokeSettingsMutedSenderLabel": {
"placeholders": {
"senderId": { "type": "String" }
}
},
"pokeSettingsUnmuteSenderAction": "Unmute",
"pokeOverflowMutePrompt": "Repeated pokes from {sender} were suppressed. Mute this sender?",
"@pokeOverflowMutePrompt": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeOverflowMuteAction": "Mute",
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
"@pokeMutedSenderConfirmation": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
"@pokeHistorySelfNoMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" }
}
},
"pokeHistorySelfWithMessage": "<{time}> You poked \"{target}\" with message: {message}",
"@pokeHistorySelfWithMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistoryIncomingNoMessage": "<{time}> \"{sender}\" pokes you",
"@pokeHistoryIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeHistoryIncomingWithMessage": "<{time}> \"{sender}\" pokes you: {message}",
"@pokeHistoryIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"clientVolumeAction": "User Volume",
"clientVolumeTitle": "{name} — volume",
"@clientVolumeTitle": {
"placeholders": { "name": { "type": "String" } }
},
"clientVolumeLabel": "{percent}%",
"@clientVolumeLabel": {
"placeholders": { "percent": { "type": "int" } }
},
"clientVolumeMuteAction": "Mute user",
"clientVolumeUnmuteAction": "Unmute user",
"clientVolumeResetAction": "Reset to default",
"permissionDenied": "Permission Denied",
"voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel"
} }
+239 -4
View File
@@ -1,23 +1,258 @@
{ {
"@@locale": "zh", "@@locale": "zh",
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Simplified Chinese translations for the MVP key set. Keys must match app_en.arb; server-provided content is NOT translated (ADR-008 / DEC-015).", "@@x-source-of-truth": "DEC-015 (register v0.9.5). Simplified Chinese for the Beta key set.",
"appTitle": "Chanora", "appTitle": "Chanora",
"homeNotProductionReadyBanner": "Alpha 版本——尚未达到生产环境质量。", "homeNotProductionReadyBanner": "Beta 版本——已接通语音收发;尚未达到生产环境质量。",
"fieldServerHost": "服务器地址", "fieldServerHost": "服务器地址",
"fieldNickname": "昵称", "fieldNickname": "昵称",
"fieldServerPassword": "服务器密码",
"fieldServerPasswordHelp": "如果服务器无需密码,留空即可。",
"fieldPassword": "密码",
"fieldDisplayName": "显示名称",
"connectAction": "连接", "connectAction": "连接",
"disconnectAction": "断开连接", "disconnectAction": "离开服务器",
"disconnectConfirmTitle": "离开服务器?",
"disconnectConfirmBody": "你将从当前服务器断开连接。",
"disconnectConfirmCancel": "留下",
"disconnectConfirmAction": "离开",
"refreshAction": "刷新", "refreshAction": "刷新",
"diagnosticsAction": "诊断信息",
"diagnosticsSaveAction": "保存导出",
"diagnosticsLiveUpdating": "实时更新中",
"shareAction": "分享",
"diagnosticsSaved": "诊断导出已保存到 {path}",
"aboutAction": "关于",
"aboutVersion": "版本 {version}",
"aboutAuthor": "作者: Edison Jwa",
"aboutNonAffiliation": "Chanora 是独立项目,与 TeamSpeak 之间不存在任何附属、认可、赞助或官方关联关系。",
"aboutLicenseHeading": "许可协议",
"aboutLicenseBody": "Chanora 采用 Apache License 2.0 或 MIT License 双协议授权,使用者可任选其一。完整协议文本以 LICENSE-APACHE 与 LICENSE-MIT 形式随仓库一同分发。",
"aboutThirdPartyHeading": "第三方组件",
"aboutThirdPartyBody": "Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。",
"copyAction": "复制",
"closeAction": "关闭",
"openAction": "打开",
"cancelAction": "取消",
"retryAction": "重试",
"chatAction": "聊天",
"chatCloseAction": "关闭聊天",
"chatPanelCollapsedHint": "点击聊天按钮以继续对话",
"chatNewPrivateAction": "新建私聊",
"chatSearchClientsHint": "搜索用户...",
"chatDirectMessageAction": "私聊",
"chatPokeAction": "戳一下",
"clientInfoAction": "信息",
"startAudioAction": "启动语音",
"pttHoldToTalk": "按住说话",
"pttTransmitting": "正在发送…",
"pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。",
"pttCapabilityBadge": "对讲能力:{level}{backend}",
"pttCapabilityBoundKey": "按键:{key}",
"pttCapabilityExplainTitle": "对讲能力说明",
"pttCapabilityExplainFocusedHeading": "聚焦对讲",
"pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。",
"pttCapabilityExplainGoGlobalWindows": "在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。",
"pttCapabilityExplainGoGlobalMacos": "在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。",
"pttCapabilityExplainGoGlobalLinux": "在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。",
"pttCapabilityExplainGoGlobalIos": "在 iOS 上,Apple 不向应用开放全局热键。Chanora 使用屏幕上的按住说话按钮,并且仅在应用位于前台时响应。",
"pttCapabilityExplainGoGlobalGeneric": "当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。",
"pttConfigureAction": "配置",
"pttConfigureTitle": "配置对讲按键",
"pttConfigurePrompt": "按下您希望用于对讲的按键或鼠标侧键。",
"pttConfigureWaiting": "(等待输入…)",
"pttConfigureCaptured": "已捕获",
"pttConfigurePrivacyNote": "Chanora 不会记录具体的按键值。本对话框只会向应用提交输入类别(键盘 / 鼠标侧键)以及一个跨平台标签。",
"pttConfigureSaveAction": "保存",
"pttConfigurePortalRedirect": "您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。",
"inputMuteAction": "静音麦克风",
"inputUnmuteAction": "取消麦克风静音",
"outputMuteAction": "静音扬声器",
"outputUnmuteAction": "取消扬声器静音",
"joinChannelAction": "加入频道",
"leaveChannelAction": "离开频道",
"channelPasswordTitle": "频道密码",
"bookmarksHeading": "书签",
"bookmarksEmpty": "尚无书签。先在上方填写服务器,然后点击“保存书签”。",
"bookmarkAddAction": "保存书签",
"bookmarkAddTitle": "保存书签",
"bookmarkDeleteAction": "删除书签",
"statusIdle": "未连接", "statusIdle": "未连接",
"statusConnecting": "正在连接…", "statusConnecting": "正在连接…",
"statusConnected": "已连接到 {server}", "statusConnected": "已连接到 {server}",
"statusError": "错误:{message}", "statusError": "错误:{message}",
"statusReconnecting": "正在重新连接……第 {attempt} 次尝试,{delay} 秒后",
"statusConnectionLost": "连接已断开:{reason}",
"audioStatsLine": "发送 {sent} 帧 • 接收 {received} 帧 • 麦克风 {ptt}",
"channelsHeading": "频道", "channelsHeading": "频道",
"clientsHeading": "在线用户", "clientsHeading": "在线用户",
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线" "serverWelcomeHeading": "服务器欢迎信息",
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线",
"voiceModePtt": "按键说话",
"voiceModeContinuous": "持续发送",
"voiceModeVoiceActivity": "语音激活",
"voiceModeComingSoon": "即将推出",
"voiceModeLabel": "发送模式",
"voiceReleaseTailLabel": "释放延迟",
"voiceReleaseTailHint": "毫秒",
"voiceLeaveAction": "离开语音",
"voiceHardMuteLabel": "麦克风静音",
"voiceOutputMuteLabel": "扬声器静音",
"voiceSettingsTitle": "语音设置",
"voiceBindKeyAction": "绑定 PTT 按键",
"voiceBoundKeyLabel": "已绑定按键",
"voicePttHoldHint": "按住按钮",
"voiceMicOn": "开启",
"voiceMicOff": "关闭",
"voiceSheetTitle": "语音",
"audioOutputLabel": "音频输出",
"audioRouteSpeaker": "扬声器",
"audioRouteReceiver": "听筒",
"audioRouteBluetooth": "蓝牙",
"audioRouteWiredHeadset": "有线耳机",
"audioRouteCarAudio": "车载音频",
"audioRouteAirplay": "AirPlay",
"audioRouteUnknown": "未知",
"channelJoinAlreadyIn": "您已在此频道中。",
"channelJoinFailedPermission": "权限不足,无法加入此频道。",
"channelJoinFailedPassword": "频道密码错误。",
"channelJoinFailedFull": "频道已满。",
"channelJoinFailedFamilyFull": "频道家族人数已达上限。",
"channelJoinFailedPrivate": "此频道为私有频道。",
"channelJoinFailedTimeout": "无法加入频道:服务器响应超时。",
"channelJoinFailedFlooding": "切换频道过于频繁,请稍后再试。",
"channelJoinFailedGeneric": "无法加入频道:{message}",
"@channelJoinFailedGeneric": {
"placeholders": {
"message": { "type": "String" }
}
},
"networkPermissionTitle": "需要网络权限",
"networkPermissionBody": "Chanora 需要网络访问权限。请前往系统设置 → 隐私与安全性 → 本地网络,启用 Chanora,然后重试。",
"networkPermissionOpenSettings": "打开系统设置",
"microphonePermissionTitle": "需要麦克风权限",
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
"permissionGrantAction": "授权",
"startupPermissionsTitle": "权限",
"startupPermissionsBody": "Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。",
"startupPermissionsNotNow": "暂不",
"startupPermissionsAllow": "允许",
"audioRouteSystemDefault": "系统默认",
"audioRouteEarpiece": "听筒",
"audioRouteUsbHeadset": "USB 耳机",
"audioRouteOtherDevice": "其他设备",
"audioRouteRefreshDevices": "刷新音频设备",
"audioRouteCannotSelect": "无法选择此输出设备。",
"audioRouteChangeFailed": "无法切换音频输出。",
"iosAudioInterrupted": "系统已中断音频(电话通话)",
"iosAudioResuming": "音频正在恢复",
"linkTrustTitle": "打开外部链接?",
"linkTrustBody": "你将打开指向以下域名的链接:\n\n{domain}",
"linkTrustRememberDomain": "信任来自此域名的所有链接",
"clientInfoFetchingProfile": "正在获取 TeamSpeak 资料",
"clientInfoLoadingProfile": "正在加载资料…",
"clientInfoProfileUnavailable": "资料不可用",
"clientInfoProfileUnavailableBody": "服务器没有返回此用户的资料详情。",
"clientInfoIdentitySection": "身份",
"clientInfoMembershipSection": "成员关系",
"clientInfoConnectionSection": "连接",
"clientInfoHistorySection": "历史",
"clientInfoTransferSection": "传输",
"clientInfoClientId": "用户 ID",
"clientInfoDatabaseId": "数据库 ID",
"clientInfoUniqueId": "唯一 ID",
"clientInfoDescription": "描述",
"clientInfoAvatar": "头像",
"clientInfoServerGroups": "服务器组",
"clientInfoChannelGroup": "频道组",
"clientInfoChannelId": "频道 ID",
"clientInfoOnline": "在线时长",
"clientInfoIdle": "空闲",
"clientInfoPing": "延迟",
"clientInfoPingDeviation": "延迟偏差",
"clientInfoAddress": "地址",
"clientInfoPacketLossClientToServer": "丢包 C->S",
"clientInfoPacketLossServerToClient": "丢包 S->C",
"clientInfoFirstConnected": "首次连接",
"clientInfoLastConnected": "上次连接",
"clientInfoConnections": "连接次数",
"clientInfoDownloadedMonth": "本月下载",
"clientInfoUploadedMonth": "本月上传",
"clientInfoDownloadedTotal": "总下载",
"clientInfoUploadedTotal": "总上传",
"clientInfoUnknown": "未知",
"clientInfoHidden": "隐藏",
"clientInfoNone": "无",
"pokeSettingsAction": "戳一戳通知",
"pokeSettingsTitle": "戳一戳通知",
"pokeSettingsEnableLabel": "接收戳一戳通知",
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
"pokeSettingsMutedSendersHeader": "已静音的发送者",
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
"@pokeSettingsMutedSenderLabel": {
"placeholders": {
"senderId": { "type": "String" }
}
},
"pokeSettingsUnmuteSenderAction": "取消静音",
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
"@pokeOverflowMutePrompt": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeOverflowMuteAction": "静音",
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
"@pokeMutedSenderConfirmation": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
"@pokeHistorySelfNoMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" }
}
},
"pokeHistorySelfWithMessage": "<{time}> 你戳了“{target}”一下,消息:{message}",
"@pokeHistorySelfWithMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistoryIncomingNoMessage": "<{time}> “{sender}”戳了你一下",
"@pokeHistoryIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeHistoryIncomingWithMessage": "<{time}> “{sender}”戳了你一下:{message}",
"@pokeHistoryIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"clientVolumeAction": "用户音量",
"clientVolumeTitle": "{name} — 音量",
"clientVolumeLabel": "{percent}%",
"clientVolumeMuteAction": "静音该用户",
"clientVolumeUnmuteAction": "取消静音",
"clientVolumeResetAction": "恢复默认",
"permissionDenied": "权限被拒绝",
"voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言"
} }
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ class AppL10nEn extends AppL10n {
@override @override
String get homeNotProductionReadyBanner => String get homeNotProductionReadyBanner =>
'Alpha build — not production ready.'; 'Beta build — voice in/out wired; not production ready.';
@override @override
String get fieldServerHost => 'Server address'; String get fieldServerHost => 'Server address';
@@ -21,15 +21,244 @@ class AppL10nEn extends AppL10n {
@override @override
String get fieldNickname => 'Nickname'; String get fieldNickname => 'Nickname';
@override
String get fieldServerPassword => 'Server password';
@override
String get fieldServerPasswordHelp =>
'Leave blank if the server does not require one.';
@override
String get fieldPassword => 'Password';
@override
String get fieldDisplayName => 'Display name';
@override @override
String get connectAction => 'Connect'; String get connectAction => 'Connect';
@override @override
String get disconnectAction => 'Disconnect'; String get disconnectAction => 'Leave server';
@override
String get disconnectConfirmTitle => 'Leave server?';
@override
String get disconnectConfirmBody =>
'You will disconnect from the current server.';
@override
String get disconnectConfirmCancel => 'Stay';
@override
String get disconnectConfirmAction => 'Leave';
@override @override
String get refreshAction => 'Refresh'; String get refreshAction => 'Refresh';
@override
String get diagnosticsAction => 'Diagnostics';
@override
String get diagnosticsSaveAction => 'Save export';
@override
String get diagnosticsLiveUpdating => 'Live updating';
@override
String get shareAction => 'Share';
@override
String diagnosticsSaved(String path) {
return 'Diagnostic export saved to $path';
}
@override
String get aboutAction => 'About';
@override
String aboutVersion(String version) {
return 'Version $version';
}
@override
String get aboutAuthor => 'Author: Edison Jwa';
@override
String get aboutNonAffiliation =>
'Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.';
@override
String get aboutLicenseHeading => 'License';
@override
String get aboutLicenseBody =>
'Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.';
@override
String get aboutThirdPartyHeading => 'Third-party software';
@override
String get aboutThirdPartyBody =>
'Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.';
@override
String get copyAction => 'Copy';
@override
String get closeAction => 'Close';
@override
String get openAction => 'Open';
@override
String get cancelAction => 'Cancel';
@override
String get retryAction => 'Retry';
@override
String get chatAction => 'Chat';
@override
String get chatCloseAction => 'Close chat';
@override
String get chatPanelCollapsedHint =>
'Tap the chat button to continue your conversation';
@override
String get chatNewPrivateAction => 'New private chat';
@override
String get chatSearchClientsHint => 'Search clients...';
@override
String get chatDirectMessageAction => 'Private message';
@override
String get chatPokeAction => 'Poke';
@override
String get clientInfoAction => 'Info';
@override
String get startAudioAction => 'Start audio';
@override
String get pttHoldToTalk => 'Hold to talk';
@override
String get pttTransmitting => 'Transmitting…';
@override
String get pttHoldToTalkSemanticsHint =>
'Press and hold to transmit voice; release to stop.';
@override
String pttCapabilityBadge(String level, String backend) {
return 'PTT: $level ($backend)';
}
@override
String pttCapabilityBoundKey(String key) {
return 'Key: $key';
}
@override
String get pttCapabilityExplainTitle => 'Push-to-Talk capability';
@override
String get pttCapabilityExplainFocusedHeading => 'Focused PTT';
@override
String get pttCapabilityExplainFocusedBody =>
'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.';
@override
String get pttCapabilityExplainGoGlobalWindows =>
'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.';
@override
String get pttCapabilityExplainGoGlobalMacos =>
'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.';
@override
String get pttCapabilityExplainGoGlobalLinux =>
'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.';
@override
String get pttCapabilityExplainGoGlobalIos =>
'On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.';
@override
String get pttCapabilityExplainGoGlobalGeneric =>
'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.';
@override
String get pttConfigureAction => 'Configure';
@override
String get pttConfigureTitle => 'Configure Push-to-Talk binding';
@override
String get pttConfigurePrompt =>
'Press the key or mouse side button you want to use for Push-to-Talk.';
@override
String get pttConfigureWaiting => '(waiting for input…)';
@override
String get pttConfigureCaptured => 'Captured';
@override
String get pttConfigurePrivacyNote =>
'Chanora never logs the actual key value. Only the input class (keyboard / mouse-side-button) and a platform-neutral label leave this dialog.';
@override
String get pttConfigureSaveAction => 'Save';
@override
String get pttConfigurePortalRedirect =>
'Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.';
@override
String get inputMuteAction => 'Mute mic';
@override
String get inputUnmuteAction => 'Unmute mic';
@override
String get outputMuteAction => 'Mute speaker';
@override
String get outputUnmuteAction => 'Unmute speaker';
@override
String get joinChannelAction => 'Join channel';
@override
String get leaveChannelAction => 'Leave channel';
@override
String get channelPasswordTitle => 'Channel password';
@override
String get bookmarksHeading => 'Bookmarks';
@override
String get bookmarksEmpty =>
'No bookmarks yet. Enter a server above and tap \"Save bookmark\".';
@override
String get bookmarkAddAction => 'Save bookmark';
@override
String get bookmarkAddTitle => 'Save bookmark';
@override
String get bookmarkDeleteAction => 'Delete bookmark';
@override @override
String get statusIdle => 'Not connected'; String get statusIdle => 'Not connected';
@@ -46,14 +275,414 @@ class AppL10nEn extends AppL10n {
return 'Error: $message'; return 'Error: $message';
} }
@override
String statusReconnecting(int attempt, int delay) {
return 'Reconnecting… attempt $attempt, in ${delay}s';
}
@override
String statusConnectionLost(String reason) {
return 'Connection lost: $reason';
}
@override
String audioStatsLine(int sent, int received, String ptt) {
return 'TX $sent frames • RX $received frames • Mic $ptt';
}
@override @override
String get channelsHeading => 'Channels'; String get channelsHeading => 'Channels';
@override @override
String get clientsHeading => 'Online clients'; String get clientsHeading => 'Online clients';
@override
String get serverWelcomeHeading => 'Server welcome message';
@override @override
String countChannelsAndClients(int channels, int clients) { String countChannelsAndClients(int channels, int clients) {
return '$channels channels • $clients online'; return '$channels channels • $clients online';
} }
@override
String get voiceModePtt => 'PTT';
@override
String get voiceModeContinuous => 'Continuous';
@override
String get voiceModeVoiceActivity => 'Voice activity';
@override
String get voiceModeComingSoon => 'Coming soon';
@override
String get voiceModeLabel => 'Transmit mode';
@override
String get voiceReleaseTailLabel => 'Release tail';
@override
String get voiceReleaseTailHint => 'ms';
@override
String get voiceLeaveAction => 'Leave voice';
@override
String get voiceHardMuteLabel => 'Mute microphone';
@override
String get voiceOutputMuteLabel => 'Mute speakers';
@override
String get voiceSettingsTitle => 'Voice settings';
@override
String get voiceBindKeyAction => 'Bind PTT key';
@override
String get voiceBoundKeyLabel => 'Bound key';
@override
String get voicePttHoldHint => 'Hold the button';
@override
String get voiceMicOn => 'on';
@override
String get voiceMicOff => 'off';
@override
String get voiceSheetTitle => 'Voice';
@override
String get audioOutputLabel => 'Audio output';
@override
String get audioRouteSpeaker => 'Speaker';
@override
String get audioRouteReceiver => 'iPhone receiver';
@override
String get audioRouteBluetooth => 'Bluetooth';
@override
String get audioRouteWiredHeadset => 'Wired headset';
@override
String get audioRouteCarAudio => 'Car audio';
@override
String get audioRouteAirplay => 'AirPlay';
@override
String get audioRouteUnknown => 'Unknown';
@override
String get channelJoinAlreadyIn => 'Already in this channel.';
@override
String get channelJoinFailedPermission =>
'Insufficient permission to join this channel.';
@override
String get channelJoinFailedPassword => 'Wrong channel password.';
@override
String get channelJoinFailedFull => 'Channel is full.';
@override
String get channelJoinFailedFamilyFull => 'Channel family limit reached.';
@override
String get channelJoinFailedPrivate => 'This channel is private.';
@override
String get channelJoinFailedTimeout =>
'Could not join channel: the server didn\'t respond in time.';
@override
String get channelJoinFailedFlooding =>
'Too many channel switches. Please wait a moment.';
@override
String channelJoinFailedGeneric(String message) {
return 'Could not join channel: $message';
}
@override
String get networkPermissionTitle => 'Network Permission Required';
@override
String get networkPermissionBody =>
'Chanora needs permission to access the network. On macOS, go to System Settings → Privacy & Security → Local Network and enable Chanora, then try again.';
@override
String get networkPermissionOpenSettings => 'Open System Settings';
@override
String get microphonePermissionTitle => 'Microphone Permission Required';
@override
String get microphonePermissionBody =>
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
@override
String get microphonePermissionRequiredForVoice =>
'Microphone permission is required for voice transmission.';
@override
String get permissionGrantAction => 'Grant';
@override
String get startupPermissionsTitle => 'Permissions';
@override
String get startupPermissionsBody =>
'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.';
@override
String get startupPermissionsNotNow => 'Not now';
@override
String get startupPermissionsAllow => 'Allow';
@override
String get audioRouteSystemDefault => 'System default';
@override
String get audioRouteEarpiece => 'Earpiece';
@override
String get audioRouteUsbHeadset => 'USB headset';
@override
String get audioRouteOtherDevice => 'Other device';
@override
String get audioRouteRefreshDevices => 'Refresh audio devices';
@override
String get audioRouteCannotSelect => 'This output cannot be selected.';
@override
String get audioRouteChangeFailed => 'Could not change audio output.';
@override
String get iosAudioInterrupted => 'Audio interrupted by system (phone call)';
@override
String get iosAudioResuming => 'Audio resuming';
@override
String get linkTrustTitle => 'Open external link?';
@override
String linkTrustBody(String domain) {
return 'You are about to open a link to:\n\n$domain';
}
@override
String get linkTrustRememberDomain => 'Trust all links from this domain';
@override
String get clientInfoFetchingProfile => 'Fetching TeamSpeak profile';
@override
String get clientInfoLoadingProfile => 'Loading profile...';
@override
String get clientInfoProfileUnavailable => 'Profile unavailable';
@override
String get clientInfoProfileUnavailableBody =>
'The server did not return profile details for this client.';
@override
String get clientInfoIdentitySection => 'Identity';
@override
String get clientInfoMembershipSection => 'Membership';
@override
String get clientInfoConnectionSection => 'Connection';
@override
String get clientInfoHistorySection => 'History';
@override
String get clientInfoTransferSection => 'Transfer';
@override
String get clientInfoClientId => 'Client ID';
@override
String get clientInfoDatabaseId => 'Database ID';
@override
String get clientInfoUniqueId => 'Unique ID';
@override
String get clientInfoDescription => 'Description';
@override
String get clientInfoAvatar => 'Avatar';
@override
String get clientInfoServerGroups => 'Server groups';
@override
String get clientInfoChannelGroup => 'Channel group';
@override
String get clientInfoChannelId => 'Channel ID';
@override
String get clientInfoOnline => 'Online';
@override
String get clientInfoIdle => 'Idle';
@override
String get clientInfoPing => 'Ping';
@override
String get clientInfoPingDeviation => 'Ping deviation';
@override
String get clientInfoAddress => 'Address';
@override
String get clientInfoPacketLossClientToServer => 'Packet loss C->S';
@override
String get clientInfoPacketLossServerToClient => 'Packet loss S->C';
@override
String get clientInfoFirstConnected => 'First connected';
@override
String get clientInfoLastConnected => 'Last connected';
@override
String get clientInfoConnections => 'Connections';
@override
String get clientInfoDownloadedMonth => 'Downloaded this month';
@override
String get clientInfoUploadedMonth => 'Uploaded this month';
@override
String get clientInfoDownloadedTotal => 'Downloaded total';
@override
String get clientInfoUploadedTotal => 'Uploaded total';
@override
String get clientInfoUnknown => 'Unknown';
@override
String get clientInfoHidden => 'Hidden';
@override
String get clientInfoNone => 'None';
@override
String get pokeSettingsAction => 'Poke notifications';
@override
String get pokeSettingsTitle => 'Poke notifications';
@override
String get pokeSettingsEnableLabel => 'Notify me about pokes';
@override
String get pokeSettingsEnableDescription =>
'Show local notifications for incoming pokes when this is on.';
@override
String get pokeSettingsMutedSendersHeader => 'Muted senders';
@override
String get pokeSettingsMutedSendersEmpty => 'No muted poke senders.';
@override
String pokeSettingsMutedSenderLabel(String senderId) {
return 'Client ID $senderId';
}
@override
String get pokeSettingsUnmuteSenderAction => 'Unmute';
@override
String pokeOverflowMutePrompt(String sender) {
return 'Repeated pokes from $sender were suppressed. Mute this sender?';
}
@override
String get pokeOverflowMuteAction => 'Mute';
@override
String pokeMutedSenderConfirmation(String sender) {
return 'Muted pokes from $sender';
}
@override
String pokeHistorySelfNoMessage(String time, String target) {
return '<$time> You poked \"$target\".';
}
@override
String pokeHistorySelfWithMessage(
String time,
String target,
String message,
) {
return '<$time> You poked \"$target\" with message: $message';
}
@override
String pokeHistoryIncomingNoMessage(String time, String sender) {
return '<$time> \"$sender\" pokes you';
}
@override
String pokeHistoryIncomingWithMessage(
String time,
String sender,
String message,
) {
return '<$time> \"$sender\" pokes you: $message';
}
@override
String get clientVolumeAction => 'User Volume';
@override
String clientVolumeTitle(String name) {
return '$name — volume';
}
@override
String clientVolumeLabel(int percent) {
return '$percent%';
}
@override
String get clientVolumeMuteAction => 'Mute user';
@override
String get clientVolumeUnmuteAction => 'Unmute user';
@override
String get clientVolumeResetAction => 'Reset to default';
@override
String get permissionDenied => 'Permission Denied';
@override
String get voiceTalkPowerBlocked =>
'Insufficient talk power to speak in this channel';
} }
@@ -12,7 +12,7 @@ class AppL10nZh extends AppL10n {
String get appTitle => 'Chanora'; String get appTitle => 'Chanora';
@override @override
String get homeNotProductionReadyBanner => 'Alpha 版本——尚未达到生产环境质量。'; String get homeNotProductionReadyBanner => 'Beta 版本——已接通语音收发;尚未达到生产环境质量。';
@override @override
String get fieldServerHost => '服务器地址'; String get fieldServerHost => '服务器地址';
@@ -20,15 +20,237 @@ class AppL10nZh extends AppL10n {
@override @override
String get fieldNickname => '昵称'; String get fieldNickname => '昵称';
@override
String get fieldServerPassword => '服务器密码';
@override
String get fieldServerPasswordHelp => '如果服务器无需密码,留空即可。';
@override
String get fieldPassword => '密码';
@override
String get fieldDisplayName => '显示名称';
@override @override
String get connectAction => '连接'; String get connectAction => '连接';
@override @override
String get disconnectAction => '断开连接'; String get disconnectAction => '离开服务器';
@override
String get disconnectConfirmTitle => '离开服务器?';
@override
String get disconnectConfirmBody => '你将从当前服务器断开连接。';
@override
String get disconnectConfirmCancel => '留下';
@override
String get disconnectConfirmAction => '离开';
@override @override
String get refreshAction => '刷新'; String get refreshAction => '刷新';
@override
String get diagnosticsAction => '诊断信息';
@override
String get diagnosticsSaveAction => '保存导出';
@override
String get diagnosticsLiveUpdating => '实时更新中';
@override
String get shareAction => '分享';
@override
String diagnosticsSaved(String path) {
return '诊断导出已保存到 $path';
}
@override
String get aboutAction => '关于';
@override
String aboutVersion(String version) {
return '版本 $version';
}
@override
String get aboutAuthor => '作者: Edison Jwa';
@override
String get aboutNonAffiliation =>
'Chanora 是独立项目,与 TeamSpeak 之间不存在任何附属、认可、赞助或官方关联关系。';
@override
String get aboutLicenseHeading => '许可协议';
@override
String get aboutLicenseBody =>
'Chanora 采用 Apache License 2.0 或 MIT License 双协议授权,使用者可任选其一。完整协议文本以 LICENSE-APACHE 与 LICENSE-MIT 形式随仓库一同分发。';
@override
String get aboutThirdPartyHeading => '第三方组件';
@override
String get aboutThirdPartyBody =>
'Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。';
@override
String get copyAction => '复制';
@override
String get closeAction => '关闭';
@override
String get openAction => '打开';
@override
String get cancelAction => '取消';
@override
String get retryAction => '重试';
@override
String get chatAction => '聊天';
@override
String get chatCloseAction => '关闭聊天';
@override
String get chatPanelCollapsedHint => '点击聊天按钮以继续对话';
@override
String get chatNewPrivateAction => '新建私聊';
@override
String get chatSearchClientsHint => '搜索用户...';
@override
String get chatDirectMessageAction => '私聊';
@override
String get chatPokeAction => '戳一下';
@override
String get clientInfoAction => '信息';
@override
String get startAudioAction => '启动语音';
@override
String get pttHoldToTalk => '按住说话';
@override
String get pttTransmitting => '正在发送…';
@override
String get pttHoldToTalkSemanticsHint => '按住进行语音发送,松开停止。';
@override
String pttCapabilityBadge(String level, String backend) {
return '对讲能力:$level$backend';
}
@override
String pttCapabilityBoundKey(String key) {
return '按键:$key';
}
@override
String get pttCapabilityExplainTitle => '对讲能力说明';
@override
String get pttCapabilityExplainFocusedHeading => '聚焦对讲';
@override
String get pttCapabilityExplainFocusedBody =>
'Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。';
@override
String get pttCapabilityExplainGoGlobalWindows =>
'在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。';
@override
String get pttCapabilityExplainGoGlobalMacos =>
'在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。';
@override
String get pttCapabilityExplainGoGlobalLinux =>
'在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。';
@override
String get pttCapabilityExplainGoGlobalIos =>
'在 iOS 上,Apple 不向应用开放全局热键。Chanora 使用屏幕上的按住说话按钮,并且仅在应用位于前台时响应。';
@override
String get pttCapabilityExplainGoGlobalGeneric =>
'当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。';
@override
String get pttConfigureAction => '配置';
@override
String get pttConfigureTitle => '配置对讲按键';
@override
String get pttConfigurePrompt => '按下您希望用于对讲的按键或鼠标侧键。';
@override
String get pttConfigureWaiting => '(等待输入…)';
@override
String get pttConfigureCaptured => '已捕获';
@override
String get pttConfigurePrivacyNote =>
'Chanora 不会记录具体的按键值。本对话框只会向应用提交输入类别(键盘 / 鼠标侧键)以及一个跨平台标签。';
@override
String get pttConfigureSaveAction => '保存';
@override
String get pttConfigurePortalRedirect => '您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。';
@override
String get inputMuteAction => '静音麦克风';
@override
String get inputUnmuteAction => '取消麦克风静音';
@override
String get outputMuteAction => '静音扬声器';
@override
String get outputUnmuteAction => '取消扬声器静音';
@override
String get joinChannelAction => '加入频道';
@override
String get leaveChannelAction => '离开频道';
@override
String get channelPasswordTitle => '频道密码';
@override
String get bookmarksHeading => '书签';
@override
String get bookmarksEmpty => '尚无书签。先在上方填写服务器,然后点击“保存书签”。';
@override
String get bookmarkAddAction => '保存书签';
@override
String get bookmarkAddTitle => '保存书签';
@override
String get bookmarkDeleteAction => '删除书签';
@override @override
String get statusIdle => '未连接'; String get statusIdle => '未连接';
@@ -45,14 +267,407 @@ class AppL10nZh extends AppL10n {
return '错误:$message'; return '错误:$message';
} }
@override
String statusReconnecting(int attempt, int delay) {
return '正在重新连接……第 $attempt 次尝试,$delay 秒后';
}
@override
String statusConnectionLost(String reason) {
return '连接已断开:$reason';
}
@override
String audioStatsLine(int sent, int received, String ptt) {
return '发送 $sent 帧 • 接收 $received 帧 • 麦克风 $ptt';
}
@override @override
String get channelsHeading => '频道'; String get channelsHeading => '频道';
@override @override
String get clientsHeading => '在线用户'; String get clientsHeading => '在线用户';
@override
String get serverWelcomeHeading => '服务器欢迎信息';
@override @override
String countChannelsAndClients(int channels, int clients) { String countChannelsAndClients(int channels, int clients) {
return '$channels 个频道 • $clients 在线'; return '$channels 个频道 • $clients 在线';
} }
@override
String get voiceModePtt => '按键说话';
@override
String get voiceModeContinuous => '持续发送';
@override
String get voiceModeVoiceActivity => '语音激活';
@override
String get voiceModeComingSoon => '即将推出';
@override
String get voiceModeLabel => '发送模式';
@override
String get voiceReleaseTailLabel => '释放延迟';
@override
String get voiceReleaseTailHint => '毫秒';
@override
String get voiceLeaveAction => '离开语音';
@override
String get voiceHardMuteLabel => '麦克风静音';
@override
String get voiceOutputMuteLabel => '扬声器静音';
@override
String get voiceSettingsTitle => '语音设置';
@override
String get voiceBindKeyAction => '绑定 PTT 按键';
@override
String get voiceBoundKeyLabel => '已绑定按键';
@override
String get voicePttHoldHint => '按住按钮';
@override
String get voiceMicOn => '开启';
@override
String get voiceMicOff => '关闭';
@override
String get voiceSheetTitle => '语音';
@override
String get audioOutputLabel => '音频输出';
@override
String get audioRouteSpeaker => '扬声器';
@override
String get audioRouteReceiver => '听筒';
@override
String get audioRouteBluetooth => '蓝牙';
@override
String get audioRouteWiredHeadset => '有线耳机';
@override
String get audioRouteCarAudio => '车载音频';
@override
String get audioRouteAirplay => 'AirPlay';
@override
String get audioRouteUnknown => '未知';
@override
String get channelJoinAlreadyIn => '您已在此频道中。';
@override
String get channelJoinFailedPermission => '权限不足,无法加入此频道。';
@override
String get channelJoinFailedPassword => '频道密码错误。';
@override
String get channelJoinFailedFull => '频道已满。';
@override
String get channelJoinFailedFamilyFull => '频道家族人数已达上限。';
@override
String get channelJoinFailedPrivate => '此频道为私有频道。';
@override
String get channelJoinFailedTimeout => '无法加入频道:服务器响应超时。';
@override
String get channelJoinFailedFlooding => '切换频道过于频繁,请稍后再试。';
@override
String channelJoinFailedGeneric(String message) {
return '无法加入频道:$message';
}
@override
String get networkPermissionTitle => '需要网络权限';
@override
String get networkPermissionBody =>
'Chanora 需要网络访问权限。请前往系统设置 → 隐私与安全性 → 本地网络,启用 Chanora,然后重试。';
@override
String get networkPermissionOpenSettings => '打开系统设置';
@override
String get microphonePermissionTitle => '需要麦克风权限';
@override
String get microphonePermissionBody =>
'Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。';
@override
String get microphonePermissionRequiredForVoice => '语音发送需要麦克风权限。';
@override
String get permissionGrantAction => '授权';
@override
String get startupPermissionsTitle => '权限';
@override
String get startupPermissionsBody =>
'Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。';
@override
String get startupPermissionsNotNow => '暂不';
@override
String get startupPermissionsAllow => '允许';
@override
String get audioRouteSystemDefault => '系统默认';
@override
String get audioRouteEarpiece => '听筒';
@override
String get audioRouteUsbHeadset => 'USB 耳机';
@override
String get audioRouteOtherDevice => '其他设备';
@override
String get audioRouteRefreshDevices => '刷新音频设备';
@override
String get audioRouteCannotSelect => '无法选择此输出设备。';
@override
String get audioRouteChangeFailed => '无法切换音频输出。';
@override
String get iosAudioInterrupted => '系统已中断音频(电话通话)';
@override
String get iosAudioResuming => '音频正在恢复';
@override
String get linkTrustTitle => '打开外部链接?';
@override
String linkTrustBody(String domain) {
return '你将打开指向以下域名的链接:\n\n$domain';
}
@override
String get linkTrustRememberDomain => '信任来自此域名的所有链接';
@override
String get clientInfoFetchingProfile => '正在获取 TeamSpeak 资料';
@override
String get clientInfoLoadingProfile => '正在加载资料…';
@override
String get clientInfoProfileUnavailable => '资料不可用';
@override
String get clientInfoProfileUnavailableBody => '服务器没有返回此用户的资料详情。';
@override
String get clientInfoIdentitySection => '身份';
@override
String get clientInfoMembershipSection => '成员关系';
@override
String get clientInfoConnectionSection => '连接';
@override
String get clientInfoHistorySection => '历史';
@override
String get clientInfoTransferSection => '传输';
@override
String get clientInfoClientId => '用户 ID';
@override
String get clientInfoDatabaseId => '数据库 ID';
@override
String get clientInfoUniqueId => '唯一 ID';
@override
String get clientInfoDescription => '描述';
@override
String get clientInfoAvatar => '头像';
@override
String get clientInfoServerGroups => '服务器组';
@override
String get clientInfoChannelGroup => '频道组';
@override
String get clientInfoChannelId => '频道 ID';
@override
String get clientInfoOnline => '在线时长';
@override
String get clientInfoIdle => '空闲';
@override
String get clientInfoPing => '延迟';
@override
String get clientInfoPingDeviation => '延迟偏差';
@override
String get clientInfoAddress => '地址';
@override
String get clientInfoPacketLossClientToServer => '丢包 C->S';
@override
String get clientInfoPacketLossServerToClient => '丢包 S->C';
@override
String get clientInfoFirstConnected => '首次连接';
@override
String get clientInfoLastConnected => '上次连接';
@override
String get clientInfoConnections => '连接次数';
@override
String get clientInfoDownloadedMonth => '本月下载';
@override
String get clientInfoUploadedMonth => '本月上传';
@override
String get clientInfoDownloadedTotal => '总下载';
@override
String get clientInfoUploadedTotal => '总上传';
@override
String get clientInfoUnknown => '未知';
@override
String get clientInfoHidden => '隐藏';
@override
String get clientInfoNone => '';
@override
String get pokeSettingsAction => '戳一戳通知';
@override
String get pokeSettingsTitle => '戳一戳通知';
@override
String get pokeSettingsEnableLabel => '接收戳一戳通知';
@override
String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。';
@override
String get pokeSettingsMutedSendersHeader => '已静音的发送者';
@override
String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。';
@override
String pokeSettingsMutedSenderLabel(String senderId) {
return '用户 ID $senderId';
}
@override
String get pokeSettingsUnmuteSenderAction => '取消静音';
@override
String pokeOverflowMutePrompt(String sender) {
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
}
@override
String get pokeOverflowMuteAction => '静音';
@override
String pokeMutedSenderConfirmation(String sender) {
return '已静音来自 $sender 的戳一戳';
}
@override
String pokeHistorySelfNoMessage(String time, String target) {
return '<$time> 你戳了“$target”一下。';
}
@override
String pokeHistorySelfWithMessage(
String time,
String target,
String message,
) {
return '<$time> 你戳了“$target”一下,消息:$message';
}
@override
String pokeHistoryIncomingNoMessage(String time, String sender) {
return '<$time> “$sender”戳了你一下';
}
@override
String pokeHistoryIncomingWithMessage(
String time,
String sender,
String message,
) {
return '<$time> “$sender”戳了你一下:$message';
}
@override
String get clientVolumeAction => '用户音量';
@override
String clientVolumeTitle(String name) {
return '$name — 音量';
}
@override
String clientVolumeLabel(int percent) {
return '$percent%';
}
@override
String get clientVolumeMuteAction => '静音该用户';
@override
String get clientVolumeUnmuteAction => '取消静音';
@override
String get clientVolumeResetAction => '恢复默认';
@override
String get permissionDenied => '权限被拒绝';
@override
String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言';
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
class AndroidAudioOutputDevice {
const AndroidAudioOutputDevice({
required this.id,
required this.name,
required this.type,
required this.isSelected,
required this.isAvailableForCommunication,
});
final String id;
final String name;
final String type;
final bool isSelected;
final bool isAvailableForCommunication;
factory AndroidAudioOutputDevice.fromMap(Map<dynamic, dynamic> map) {
return AndroidAudioOutputDevice(
id: map['id']?.toString() ?? '',
name: map['name']?.toString() ?? '',
type: map['type']?.toString() ?? 'unknown',
isSelected: map['isSelected'] == true,
isAvailableForCommunication: map['isAvailableForCommunication'] == true,
);
}
}
List<AndroidAudioOutputDevice> parseAndroidAudioOutputDevices(
List<dynamic> raw,
) {
return raw
.whereType<Map<dynamic, dynamic>>()
.map(AndroidAudioOutputDevice.fromMap)
.toList();
}
AndroidAudioOutputDevice? selectedAndroidAudioOutputDevice(
List<AndroidAudioOutputDevice> devices,
) {
return devices.where((device) => device.isSelected).firstOrNull;
}
@@ -0,0 +1,374 @@
/// SDD-106 `AndroidPermissionRequester` — Dart integration layer.
///
/// Trace:
/// - SDD-106 (Android runtime acquisition of `RECORD_AUDIO` with
/// listen-only fallback; bridge event surface §5).
/// - SRS-209 (runtime microphone permission acquisition; fail-safe
/// to listen-only on denial; user-visible path to grant).
///
/// Responsibilities:
/// * Subscribe to the Kotlin-side `MethodChannel`
/// `app.chanora/android_permissions` for `permissionStateChanged`
/// invocations emitted by `AndroidPermissionRequester.kt`
/// (Kotlin → Dart).
/// * Provide imperative Dart → Kotlin entry points
/// `requestRecordAudio` and `openAppSettings` so the UI can drive
/// the runtime request flow per SDD-106 §1–§3.
/// * Expose the latest resolved state as a [ValueListenable] so UI
/// surfaces (the listen-only banner per SRS-209; the pre-`voice_join`
/// gate per SDD-106 §1) can react without polling.
///
/// ## Non-Android short-circuit
///
/// On Linux / macOS / Windows / iOS / web, RECORD_AUDIO is not gated
/// by this channel (desktop has no Android runtime-permission concept;
/// iOS uses AVAudioSession authorisation which is owned by the audio
/// engine itself per SDD-101). The MethodChannel is therefore never
/// constructed off-Android. The state listenable stays at
/// [AndroidRecordAudioPermissionState.granted] so the voice-join gate
/// (in `main.dart`) becomes a no-op on those platforms.
///
/// ## No global statics
///
/// Following the pattern established by `BackIntentService`
/// (SDD-028), this class is constructor-injected. The host app
/// instantiates one instance at startup and passes it through the
/// widget tree.
///
/// ## Bridge event surface (SDD-106 §5)
///
/// SDD-106 §5 calls for a `BridgeEvent::PermissionState` variant on
/// the existing Rust → Dart event stream so the audio engine can
/// observe the resolved state for the transmit-clamp described in
/// SDD-106 §6. That Rust-side variant is intentionally deferred to a
/// follow-up Wave touching `chanora_bridge`; the current Wave 2B
/// Dart-only path is sufficient for the UI obligations of SRS-209
/// (listen-only banner; path to grant). See the structured handoff
/// note attached to this task for the rationale.
library;
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// MethodChannel name shared with Kotlin `MethodChannels.ANDROID_PERMISSIONS`.
///
/// Trace: SDD-106 §5.
@visibleForTesting
const String androidPermissionsChannelName = 'app.chanora/android_permissions';
/// Inbound (Kotlin → Dart) method invoked whenever the resolved
/// `RECORD_AUDIO` permission state transitions.
///
/// Payload schema: `{permission: String, state: String}` where
/// `state` is one of `"Granted"`, `"Denied"`, `"PermanentlyDenied"`.
///
/// Trace: SDD-106 §5; mirrors Kotlin
/// `MethodChannels.METHOD_PERMISSION_STATE_CHANGED`.
@visibleForTesting
const String methodPermissionStateChanged = 'permissionStateChanged';
/// Outbound (Dart → Kotlin) method that asks the Android requester to
/// (re-)prompt for `RECORD_AUDIO`. The result is delivered
/// asynchronously via [methodPermissionStateChanged].
///
/// Trace: SDD-106 §1.
@visibleForTesting
const String methodRequestRecordAudio = 'requestRecordAudio';
@visibleForTesting
const String methodRequestStartupPermissions = 'requestStartupPermissions';
/// Outbound (Dart → Kotlin) method that deep-links to the application's
/// Android Settings page so the user can re-grant a permanently-denied
/// permission.
///
/// Trace: SDD-106 §3.
@visibleForTesting
const String methodOpenAppSettings = 'openAppSettings';
/// Discrete states surfaced to the Dart UI.
///
/// Trace: SDD-106 §5 (state machine).
enum AndroidRecordAudioPermissionState {
/// Permission granted; microphone capture is allowed.
granted,
/// Permission denied but the user may still be re-prompted by
/// invoking [AndroidPermissionsService.ensureRecordAudio] again.
denied,
/// Permission denied with "Don't ask again" or revoked from system
/// Settings. The UI must deep-link to app settings via
/// [AndroidPermissionsService.openAppSettings] (SDD-106 §3).
permanentlyDenied,
/// No resolved state yet (cold launch before the first emission, or
/// non-Android host before short-circuit). The voice-join gate
/// treats this as "request before joining".
unknown,
}
enum AndroidPermissionKind {
recordAudio('android.permission.RECORD_AUDIO'),
bluetoothConnect('android.permission.BLUETOOTH_CONNECT'),
postNotifications('android.permission.POST_NOTIFICATIONS');
const AndroidPermissionKind(this.permission);
final String permission;
}
/// Maps the Kotlin-side `PermissionState` string into the Dart enum.
AndroidRecordAudioPermissionState _parseState(String? raw) {
switch (raw) {
case 'Granted':
return AndroidRecordAudioPermissionState.granted;
case 'Denied':
return AndroidRecordAudioPermissionState.denied;
case 'PermanentlyDenied':
return AndroidRecordAudioPermissionState.permanentlyDenied;
default:
return AndroidRecordAudioPermissionState.unknown;
}
}
/// Dart-side integration for `AndroidPermissionRequester` (SDD-106).
///
/// Trace: SDD-106, SRS-209.
class AndroidPermissionsService {
/// Construct a service bound to [channel]. Injected for testability;
/// production code uses the default channel keyed on
/// [androidPermissionsChannelName].
AndroidPermissionsService({MethodChannel? channel})
: _channel =
channel ??
(_isAndroid
? const MethodChannel(androidPermissionsChannelName)
: null);
/// Platform-detection seam. Web counts as non-Android.
static bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
/// The underlying channel, or `null` on non-Android (and unset in
/// tests that omit the optional argument).
final MethodChannel? _channel;
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
ValueNotifier<AndroidRecordAudioPermissionState>(
// On non-Android, present as granted so the voice-join gate is a
// no-op (desktop / iOS have separate audio-permission paths owned
// elsewhere; see SDD-101 for iOS).
_isAndroid
? AndroidRecordAudioPermissionState.unknown
: AndroidRecordAudioPermissionState.granted,
);
final Map<AndroidPermissionKind, ValueNotifier<AndroidRecordAudioPermissionState>>
_extraStates = {
for (final kind in AndroidPermissionKind.values)
kind: ValueNotifier<AndroidRecordAudioPermissionState>(
_isAndroid
? AndroidRecordAudioPermissionState.unknown
: AndroidRecordAudioPermissionState.granted,
),
};
bool _started = false;
/// Latest known [RECORD_AUDIO] state. Defaults to
/// [AndroidRecordAudioPermissionState.unknown] on Android and
/// [AndroidRecordAudioPermissionState.granted] elsewhere.
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
_state;
/// Start listening for state updates from Kotlin. Idempotent.
///
/// On non-Android this is a no-op.
///
/// Trace: SDD-106 §5.
void start() {
if (_started) return;
_started = true;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(_handle);
}
/// Stop listening. Idempotent.
void stop() {
if (!_started) return;
_started = false;
final ch = _channel;
if (ch == null) return;
ch.setMethodCallHandler(null);
}
Future<dynamic> _handle(MethodCall call) async {
if (call.method != methodPermissionStateChanged) return null;
final args = call.arguments;
if (args is! Map) return null;
final permission = args['permission'];
if (permission is! String) return null;
final state = _parseState(args['state'] as String?);
if (permission == AndroidPermissionKind.recordAudio.permission) {
_state.value = state;
}
for (final entry in _extraStates.entries) {
if (entry.key.permission == permission) {
entry.value.value = state;
}
}
return null;
}
ValueListenable<AndroidRecordAudioPermissionState> permissionState(
AndroidPermissionKind kind,
) {
if (kind == AndroidPermissionKind.recordAudio) {
return _state;
}
return _extraStates[kind]!;
}
/// Request the system permission. Invokes the Kotlin requester and
/// then, if the platform synchronously resolves the request, returns
/// the resolved state without parking for the listener; otherwise
/// awaits the next `permissionStateChanged` emission.
///
/// On non-Android, resolves immediately with
/// [AndroidRecordAudioPermissionState.granted].
///
/// Trace: SDD-106 §1, §3.
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
final ch = _channel;
if (ch == null) {
return AndroidRecordAudioPermissionState.granted;
}
if (_state.value == AndroidRecordAudioPermissionState.granted) {
return AndroidRecordAudioPermissionState.granted;
}
// Snapshot the pre-invocation state. New Android hosts return the
// resolved Kotlin PermissionState string from requestRecordAudio;
// older/test hosts may still return null and rely only on the
// permissionStateChanged callback below.
final preInvokeState = _state.value;
String? returnedState;
try {
returnedState = await ch.invokeMethod<String>(methodRequestRecordAudio);
} catch (_) {
// Channel-side failure (e.g. missing handler in a debug build).
// Fall back to whatever state we currently hold; if still
// unknown, surface unknown so the caller can choose its own
// policy. Per SRS-209 the voice-join gate treats unknown as
// "proceed in listen-only".
return _state.value;
}
final parsedReturnedState = _parseState(returnedState);
if (parsedReturnedState != AndroidRecordAudioPermissionState.unknown) {
_state.value = parsedReturnedState;
return parsedReturnedState;
}
// M1 fix (corrected): only short-circuit if the platform changed
// the state synchronously to a resolved value. A still-unknown
// state means we must wait. A still-equal-to-pre-invoke state
// means we also must wait (the platform is re-prompting; the
// resolution arrives asynchronously after the user interacts with
// the dialog).
if (_state.value != preInvokeState &&
_state.value != AndroidRecordAudioPermissionState.unknown) {
return _state.value;
}
// Otherwise we wait for the listener to observe a state
// transition. Bounded wait to avoid hanging the voice-join flow
// if the platform dialog is dismissed without a result. The
// Kotlin requester treats dismissal as Denied (see
// AndroidPermissionRequester.handleRequestPermissionsResult) so
// this is defence-in-depth.
final completer = Completer<AndroidRecordAudioPermissionState>();
void listener() {
if (!completer.isCompleted &&
_state.value != preInvokeState &&
_state.value != AndroidRecordAudioPermissionState.unknown) {
completer.complete(_state.value);
}
}
_state.addListener(listener);
try {
return await completer.future.timeout(
const Duration(seconds: 30),
onTimeout: () => _state.value,
);
} finally {
_state.removeListener(listener);
}
}
Future<Map<AndroidPermissionKind, AndroidRecordAudioPermissionState>>
ensureStartupPermissions() async {
final ch = _channel;
if (ch == null) {
return {
for (final kind in AndroidPermissionKind.values)
kind: AndroidRecordAudioPermissionState.granted,
};
}
Map<dynamic, dynamic>? returned;
try {
returned = await ch.invokeMethod<Map<dynamic, dynamic>>(
methodRequestStartupPermissions,
);
} catch (_) {
return {
for (final kind in AndroidPermissionKind.values)
kind: permissionState(kind).value,
};
}
final resolved = <AndroidPermissionKind, AndroidRecordAudioPermissionState>{};
for (final kind in AndroidPermissionKind.values) {
final parsed = _parseState(returned?[kind.permission] as String?);
if (kind == AndroidPermissionKind.recordAudio) {
_state.value = parsed;
} else {
_extraStates[kind]!.value = parsed;
}
resolved[kind] = parsed;
}
return resolved;
}
/// Deep-link to the system app settings page for the permanently
/// denied case (SDD-106 §3). On non-Android, a no-op.
Future<void> openAppSettings() async {
final ch = _channel;
if (ch == null) return;
try {
await ch.invokeMethod<void>(methodOpenAppSettings);
} catch (e, st) {
// Best-effort; failure to launch settings is non-fatal. The
// Kotlin side already logs ActivityNotFoundException.
developer.log(
'openAppSettings failed',
name: 'AndroidPermissionsService',
error: e,
stackTrace: st,
);
}
}
/// Release the state notifier. Test helper; production keeps the
/// service alive for the lifetime of the app.
@visibleForTesting
void dispose() {
stop();
_state.dispose();
for (final notifier in _extraStates.values) {
notifier.dispose();
}
}
}
@@ -0,0 +1,193 @@
import 'dart:io' show Directory, File;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import '../src/rust/api.dart' as rust;
const String appSemverBaseline = 'v0.1.0';
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
typedef StorageDirectoryProvider = Future<Directory> Function();
typedef StorageInitializer = Future<void> Function(String dir);
Future<void>? _storageInitFuture;
Future<void>? _cacheInitFuture;
Future<void>? _vadBootstrapFuture;
StorageDirectoryProvider _storageDirectoryProvider =
getApplicationSupportDirectory;
StorageDirectoryProvider _cacheDirectoryProvider = getApplicationCacheDirectory;
StorageInitializer _storageInitializer = _defaultStorageInitializer;
StorageInitializer _cacheInitializer = _defaultCacheInitializer;
Future<void> _defaultStorageInitializer(String dir) {
return rust.initStorage(dir: dir);
}
Future<void> _defaultCacheInitializer(String dir) {
return rust.initCache(dir: dir);
}
Future<File> _copyBundledAssetToDocuments({
required String assetPath,
required String fileName,
}) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
if (await file.exists() && await file.length() == bytes.length) {
return file;
}
await file.writeAsBytes(bytes, flush: true);
return file;
}
Future<void> configureBundledVadModels() async {
final existing = _vadBootstrapFuture;
if (existing != null) {
await existing;
return;
}
final bootstrap = _configureBundledVadModelsImpl();
_vadBootstrapFuture = bootstrap;
try {
await bootstrap;
} catch (_) {
_vadBootstrapFuture = null;
rethrow;
}
}
Future<void> _configureBundledVadModelsImpl() async {
final silero = await _copyBundledAssetToDocuments(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
await rust.setVadModelPath(path: silero.path);
}
/// Resolve the human-readable app version displayed in About.
///
/// The semver baseline is kept in code because iOS strips pre-release
/// identifiers from `CFBundleShortVersionString`; `package_info_plus`
/// still provides the platform build counter.
Future<String> resolveAppVersion({
String semverBaseline = appSemverBaseline,
}) async {
try {
final info = await PackageInfo.fromPlatform();
return appVersionFromBuildNumber(
semverBaseline: semverBaseline,
buildNumber: info.buildNumber,
);
} catch (_) {
return semverBaseline;
}
}
String appVersionFromBuildNumber({
required String semverBaseline,
required String buildNumber,
}) {
final build = buildNumber.isEmpty ? '' : '+$buildNumber';
return '$semverBaseline$build';
}
Future<void> wireStorage() async {
final existing = _storageInitFuture;
if (existing != null) {
await existing;
return;
}
final initFuture = _wireStorageImpl();
_storageInitFuture = initFuture;
await initFuture;
}
Future<void> _wireStorageImpl() async {
var initialized = false;
try {
final dir = await _storageDirectoryProvider();
await _storageInitializer(dir.path);
initialized = true;
} catch (_) {
// Best-effort; missing storage just means no identity persistence
// and no bookmark list this session.
} finally {
if (!initialized) {
_storageInitFuture = null;
}
}
}
Future<void> wireCache() async {
final existing = _cacheInitFuture;
if (existing != null) {
await existing;
return;
}
final initFuture = _wireCacheImpl();
_cacheInitFuture = initFuture;
await initFuture;
}
Future<void> _wireCacheImpl() async {
var initialized = false;
try {
final dir = await _cacheDirectoryProvider();
await _cacheInitializer(dir.path);
initialized = true;
} catch (_) {
// Best-effort; missing cache just means protocol-owned assets are
// re-downloaded this session.
} finally {
if (!initialized) {
_cacheInitFuture = null;
}
}
}
@visibleForTesting
void debugResetStorageBootstrap({
StorageDirectoryProvider? storageDirectoryProvider,
StorageDirectoryProvider? cacheDirectoryProvider,
StorageInitializer? storageInitializer,
StorageInitializer? cacheInitializer,
}) {
_storageInitFuture = null;
_cacheInitFuture = null;
_vadBootstrapFuture = null;
_storageDirectoryProvider =
storageDirectoryProvider ?? getApplicationSupportDirectory;
_cacheDirectoryProvider =
cacheDirectoryProvider ?? getApplicationCacheDirectory;
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
_cacheInitializer = cacheInitializer ?? _defaultCacheInitializer;
}
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
final allNone = results.every((r) => r == ConnectivityResult.none);
if (allNone) return rust.BridgeNetworkState.offline;
return rust.BridgeNetworkState.online;
}
Future<void> wireConnectivity() async {
final connectivity = Connectivity();
try {
final initial = await connectivity.checkConnectivity();
rust.setNetworkState(state: _mapConnectivity(initial));
} catch (_) {}
connectivity.onConnectivityChanged.listen((results) {
rust.setNetworkState(state: _mapConnectivity(results));
});
}

Some files were not shown because too many files have changed in this diff Show More