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
111 changed files with 11852 additions and 2455 deletions
+46 -31
View File
@@ -1,34 +1,49 @@
# Environment variables set for all cargo invocations in this workspace.
# CMAKE_POLICY_VERSION_MINIMUM is required for audiopus_sys's bundled
# Opus CMake build to succeed on CMake 4.x (which removed compatibility
# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles
# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version.
# 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"
# iOS builds must set IPHONEOS_DEPLOYMENT_TARGET in the invoking script
# or Xcode build phase. Do not set it globally here: native macOS cargo
# checks also compile bundled C/C++ dependencies, and a global iOS
# deployment target makes clang try to link iPhone objects against the
# macOS SDK.
# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0).
#
# These rustflags pass -miphoneos-version-min=13.0 to the linker, ensuring
# the final binary targets iOS 13.0+. This is defense-in-depth alongside
# the IPHONEOS_DEPLOYMENT_TARGET env var above — the env var affects C
# compilation (cc crate, CMake), while these rustflags affect the final
# link step.
#
# NOTE: The canonical iOS build is done via tools/build-ios.sh, which
# sets LIBOPUS_STATIC=1, LIBOPUS_NO_PKG=1, and LIBOPUS_LIB_DIR to
# bypass audiopus_sys's CMake build entirely.
[target.aarch64-apple-ios]
rustflags = ["-C", "link-arg=-miphoneos-version-min=13.0"]
[target.aarch64-apple-ios-sim]
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
[target.x86_64-apple-ios]
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
# 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 }
+16 -12
View File
@@ -8,8 +8,10 @@ This project follows a Conventional Commits style workflow.
The v0.3.0 milestone transitions Chanora from an internal-beta voice
prototype to a cross-platform baseline client with event-driven UI,
per-user audio controls, non-self client info parity, and CI-hardened
Android / iOS / macOS / Linux builds.
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.
### Added
@@ -17,10 +19,9 @@ Android / iOS / macOS / Linux builds.
deltas (client join/leave/move/update, channel add/remove/update)
flow through a typed `ProtocolDelta` enum and update the Flutter UI
in real time. Channel switching is instant.
- **Per-user volume controls.** Each client in the snapshot gets an
independent volume slider persisted in the bridge layer. Avatar
badges show muted/deafened state. Volume adjustments take effect
immediately on the audio mix.
- **Per-client audio state visibility.** Client rows surface
muted/deafened state in avatar badges. Per-user volume UI, persistence,
and mixer wiring remain tracked as follow-up work.
- **Non-self client info parity with Qint.** The Info tab now populates
connection metadata (name, description, created, last connected,
connections, transfer, ping deviation) for other clients via an
@@ -29,9 +30,10 @@ Android / iOS / macOS / Linux builds.
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
propagated from protocol DTO through bridge API to Dart, with a
conditional l10n row in the client info sheet (en + zh).
- **Apple CoreML Silero VAD** as the preferred voice activity detector
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
available. WebRTC VAD remains the runtime fallback.
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
the private `silero-coreml` SwiftPM package is available. Product
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
baseline enables and verifies it.
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
lookups and `ts3server://` URI handling.
- **Per-ABI Android APK splitting.** `flutter build apk
@@ -50,8 +52,9 @@ Android / iOS / macOS / Linux builds.
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
audio startup.
- **Linux native audio path promoted** with ONNX Runtime bundled for
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
- **Linux native audio path promoted** with ONNX Runtime VAD assets
bundled for future `VoiceActivity` work. Desktop voice I/O works on
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
startup permission flow, and system back-button integration.
- **`SnapshotChanged` event removed.** Replaced by the typed delta
@@ -59,7 +62,8 @@ Android / iOS / macOS / Linux builds.
Flutter).
- **Prefetch crate renamed** from the PoC-era name to
`chanora_prefetch`. All docs, specs, and code updated.
- **Build number bumped to 76.**
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
packages remain versioned separately at `0.2.0-beta.1`.
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
### Fixed
Generated
+368 -16
View File
@@ -148,12 +148,111 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener 5.4.1",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener 2.5.3",
"futures-core",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-global-executor"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
dependencies = [
"async-channel 2.5.0",
"async-executor",
"async-io",
"async-lock",
"blocking",
"futures-lite",
"once_cell",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener 5.4.1",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel 2.5.0",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener 5.4.1",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -165,6 +264,57 @@ dependencies = [
"syn",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-std"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
dependencies = [
"async-channel 1.9.0",
"async-global-executor",
"async-io",
"async-lock",
"async-process",
"crossbeam-utils",
"futures-channel",
"futures-core",
"futures-io",
"futures-lite",
"gloo-timers",
"kv-log-macro",
"log",
"memchr",
"once_cell",
"pin-project-lite",
"pin-utils",
"slab",
"wasm-bindgen-futures",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -257,6 +407,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@@ -308,6 +464,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel 2.5.0",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "build-target"
version = "0.4.0"
@@ -352,6 +521,32 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cacache"
version = "13.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
dependencies = [
"async-std",
"digest 0.10.7",
"either",
"futures",
"hex",
"libc",
"memmap2",
"miette",
"reflink-copy",
"serde",
"serde_derive",
"serde_json",
"sha1",
"sha2",
"ssri",
"tempfile",
"thiserror 1.0.69",
"walkdir",
]
[[package]]
name = "cast"
version = "0.3.0"
@@ -475,11 +670,23 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "chanora_cache"
version = "0.2.0-beta.1"
dependencies = [
"cacache",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "chanora_core"
version = "0.2.0-beta.1"
dependencies = [
"chanora_audio",
"chanora_cache",
"chanora_diagnostics",
"chanora_prefetch",
"chanora_protocol",
@@ -515,7 +722,7 @@ name = "chanora_protocol"
version = "0.2.0-beta.1"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"chanora_resolver",
"futures",
"reqwest 0.13.4",
@@ -532,7 +739,7 @@ dependencies = [
[[package]]
name = "chanora_resolver"
version = "0.1.0"
version = "0.2.0-beta.1"
dependencies = [
"anyhow",
"hickory-resolver",
@@ -554,7 +761,7 @@ dependencies = [
name = "chanora_storage"
version = "0.2.0-beta.1"
dependencies = [
"base64",
"base64 0.22.1",
"chacha20poly1305",
"keyring",
"rand 0.8.6",
@@ -1239,6 +1446,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1256,7 +1469,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"event-listener 5.4.1",
"pin-project-lite",
]
@@ -1559,6 +1772,18 @@ dependencies = [
"time",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -1837,7 +2062,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -2142,6 +2367,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "kv-log-macro"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
dependencies = [
"log",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2226,6 +2460,9 @@ name = "log"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
dependencies = [
"value-bag",
]
[[package]]
name = "lru-slab"
@@ -2274,6 +2511,15 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327"
dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -2283,6 +2529,29 @@ dependencies = [
"autocfg",
]
[[package]]
name = "miette"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
dependencies = [
"miette-derive",
"once_cell",
"thiserror 1.0.69",
"unicode-width",
]
[[package]]
name = "miette-derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2813,6 +3082,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
@@ -2857,6 +3137,20 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
@@ -3183,6 +3477,18 @@ dependencies = [
"syn",
]
[[package]]
name = "reflink-copy"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27"
dependencies = [
"cfg-if",
"libc",
"rustix",
"windows",
]
[[package]]
name = "regex"
version = "1.12.3"
@@ -3218,7 +3524,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-core",
"http",
@@ -3256,7 +3562,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
@@ -3672,6 +3978,17 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3851,6 +4168,23 @@ dependencies = [
"der",
]
[[package]]
name = "ssri"
version = "9.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
dependencies = [
"base64 0.21.7",
"digest 0.10.7",
"hex",
"miette",
"serde",
"sha-1",
"sha2",
"thiserror 1.0.69",
"xxhash-rust",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4349,7 +4683,7 @@ name = "ts-bookkeeping"
version = "0.1.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"base64 0.22.1",
"heck",
"itertools 0.14.0",
"num-derive",
@@ -4370,7 +4704,7 @@ version = "0.2.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"audiopus",
"base64",
"base64 0.22.1",
"futures",
"git-testament",
"hickory-net",
@@ -4398,7 +4732,7 @@ version = "0.2.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"aes",
"base64",
"base64 0.22.1",
"curve25519-dalek-ng",
"eax",
"futures",
@@ -4427,7 +4761,7 @@ name = "tsproto-packets"
version = "0.1.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags 2.12.1",
"num-derive",
"num-traits",
@@ -4442,7 +4776,7 @@ name = "tsproto-structs"
version = "0.2.0"
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
dependencies = [
"base64",
"base64 0.22.1",
"csv",
"heck",
"once_cell",
@@ -4455,7 +4789,7 @@ name = "tsproto-structs"
version = "0.2.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"base64 0.22.1",
"csv",
"heck",
"once_cell",
@@ -4468,7 +4802,7 @@ name = "tsproto-types"
version = "0.1.0"
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags 2.12.1",
"curve25519-dalek-ng",
"elliptic-curve",
@@ -4512,6 +4846,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -4570,6 +4910,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5256,6 +5602,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xxhash-rust"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "yoke"
version = "0.8.2"
@@ -5289,7 +5641,7 @@ dependencies = [
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"event-listener 5.4.1",
"futures-core",
"futures-lite",
"hex",
+2
View File
@@ -10,6 +10,7 @@
# crates/chanora_resolver/ — TeamSpeak address resolution
# crates/chanora_state/ — snapshot, deltas, reducers
# 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_diagnostics/ — logs, redaction, export
# crates/chanora_prefetch — server-resolution prefetch cache/policy
@@ -30,6 +31,7 @@ members = [
"crates/chanora_state",
"crates/chanora_audio",
"crates/chanora_storage",
"crates/chanora_cache",
"crates/chanora_diagnostics",
"crates/chanora_prefetch",
"crates/chanora_bridge",
+8 -10
View File
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
## 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
Current documentation baseline: v0.9.2
Current documentation baseline: v0.9.x document set
Current status: Baseline Candidate
Implementation status: Not production-ready
```
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
The current engineering focus is:
- 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`;
- defining cross-platform audio behavior;
- preparing release, verification, security, privacy, and legal gates.
@@ -49,7 +49,7 @@ Current platform policy:
| 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 |
| 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 |
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
@@ -230,7 +230,7 @@ docs/
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
apps/
@@ -248,7 +248,7 @@ crates/
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.
---
@@ -395,9 +395,7 @@ docs/governance/git-commit-message-convention.md
## Development
Implementation commands will be added after the repository scaffold is finalized.
Expected future commands may include:
Common local commands include:
```bash
flutter pub get
@@ -407,7 +405,7 @@ cargo clippy
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.
---
@@ -59,6 +59,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -198,6 +199,7 @@ android {
// 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 {
@@ -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" />
@@ -1,6 +1,9 @@
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
#include "Generated.xcconfig"
// Mirror Release.xcconfig (see explanation there).
OTHER_LDFLAGS = $(inherited) -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
// 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
+1 -1
View File
@@ -23,7 +23,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/haptic_kit/ios"
SPEC CHECKSUMS:
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
+114 -111
View File
@@ -6,6 +6,24 @@ import AVFoundation
@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,
@@ -15,91 +33,28 @@ import AVFoundation
ChanoraSileroSelfTest.run()
}
// Configure the iOS AVAudioSession **category + mode** at
// app-launch time, but DEFER setActive(true) until the scene
// is foregrounded. Calling setActive in didFinishLaunching is
// racy on iOS 17+ devices: if the user launches the app from a
// cold state, the UIApplication isn't yet `.active` and
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
// (561017449) the iOS audio policy server refuses to grant
// the audio session because the app is not yet considered the
// foreground priority owner. Symptom in production builds:
// 'AVAudioSession setup failed: Error 561017449 "Session
// activation failed"' in NSLog, after which the audio engine
// is unusable until the user backgrounds + foregrounds the
// app.
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
// the launch-time .playAndRecord setup):
//
// The category itself can be set whenever; only the active
// state needs to be deferred. We listen for
// didBecomeActiveNotification and activate then. Most
// production iOS voice apps (Discord, Zoom, FaceTime) follow
// this same shape.
// 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 {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
// Mode rationale (May 2026, .voiceChat reinstated):
//
// We previously used .default mode after discovering that
// .voiceChat routed output through iOS's in-call audio
// channel, which made speaker output barely audible. That
// bug was caused by cpal's RemoteIO unit binding to a stale
// physical transducer after migrating to coreaudio-rs +
// kAudioUnitSubType_VoiceProcessingIO (see
// crates/chanora_audio/src/ios_voice_unit.rs) the route
// binding is correct under either mode because VPIO re-binds
// on overrideOutputAudioPort.
//
// .voiceChat advantages over .default:
// * Tells iOS this is a VoIP session other apps' audio
// is properly ducked/paused instead of competing.
// * Enables correct Bluetooth HFP negotiation without
// manual workarounds.
// * iOS treats the audio session as a "call" for priority
// purposes (won't be interrupted by notification sounds).
// * System-level CallKit integration (lock-screen controls).
//
// .defaultToSpeaker ensures output goes to the main speaker
// (not the earpiece) by default when no headphones are
// connected, compensating for the in-call channel's tendency
// to route to the earpiece.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
// * https://stackoverflow.com/questions/79834998 (Daily.co)
//
// Options:
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
// .allowBluetoothA2DP : permit higher-quality A2DP
// output-only Bluetooth devices.
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
// Match VPIO / Opus frame cadence to reduce callback pressure.
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
logAudioSessionState(context: "setCategory")
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "launch-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
}
// Activate the session once the app is actually foreground. The
// notification fires immediately after the cold-launch settles,
// and again on every resume-from-background both safe
// moments to call setActive(true). Repeated activation while
// already-active is a no-op per the docs.
NotificationCenter.default.addObserver(
self,
selector: #selector(activateAudioSession),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange(_:)),
@@ -124,37 +79,60 @@ import AVFoundation
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// Called by `didBecomeActiveNotification` (cold-launch settle +
/// every resume-from-background). Activates the AVAudioSession.
/// Repeated activation is a no-op when the session is already
/// active so this is safe to call on every foreground.
@objc private func activateAudioSession() {
/// 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 {
try AVAudioSession.sharedInstance().setActive(true, options: [])
NSLog("chanora_flutter: AVAudioSession activated on foreground")
// Read back the ACTUAL session state. preferredSampleRate /
// preferredIOBufferDuration are hints; iOS may pick something
// else depending on hardware + currently-engaged effects.
// Without these we can't tell whether VPIO is running at
// 48 kHz mono (what our render callback assumes) or at e.g.
// 44.1 kHz (which would explain the user's broken playback
// \u2014 our render callback would be writing samples at the
// wrong rate, causing pitch + timing artifacts).
logAudioSessionState(context: "setActive")
let s = AVAudioSession.sharedInstance()
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
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: AVAudioSession actual: " +
"sampleRate=\(s.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
"inputs=[\(ins)] " +
"outputVolume=\(s.outputVolume)"
"chanora_flutter: voice session active: " +
"sampleRate=\(session.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
)
} catch {
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
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.
@@ -219,25 +197,30 @@ import AVFoundation
}
@objc private func handleMediaServicesReset(_ notification: Notification) {
NSLog("chanora_flutter: media services reset")
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]
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
logAudioSessionState(context: "mediaServicesWereReset")
logAudioSessionState(context: "mediaServicesWereReset-voip")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
}
// P1: After rebuilding the session, send the current route class to
// Rust so it can recompute the processing policy and reset the
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
// which calls ios_restart_voice_unit.
} 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)
@@ -269,6 +252,26 @@ import AVFoundation
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":
+3 -1
View File
@@ -25,7 +25,7 @@
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<true/>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -34,6 +34,8 @@
<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>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+19 -8
View File
@@ -242,19 +242,30 @@
"clientInfoUnknown": "Unknown",
"clientInfoHidden": "Hidden",
"clientInfoNone": "None",
"pokeSnackBarClearAction": "Clear",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
"@pokeSnackBarIncomingNoMessage": {
"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" }
}
},
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
"@pokeSnackBarIncomingWithMessage": {
"pokeOverflowMuteAction": "Mute",
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
"@pokeMutedSenderConfirmation": {
"placeholders": {
"sender": { "type": "String" },
"message": { "type": "String" }
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
+19 -8
View File
@@ -191,19 +191,30 @@
"clientInfoUnknown": "未知",
"clientInfoHidden": "隐藏",
"clientInfoNone": "无",
"pokeSnackBarClearAction": "清除",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
"@pokeSnackBarIncomingNoMessage": {
"pokeSettingsAction": "戳一戳通知",
"pokeSettingsTitle": "戳一戳通知",
"pokeSettingsEnableLabel": "接收戳一戳通知",
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
"pokeSettingsMutedSendersHeader": "已静音的发送者",
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
"@pokeSettingsMutedSenderLabel": {
"placeholders": {
"senderId": { "type": "String" }
}
},
"pokeSettingsUnmuteSenderAction": "取消静音",
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
"@pokeOverflowMutePrompt": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
"@pokeSnackBarIncomingWithMessage": {
"pokeOverflowMuteAction": "静音",
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
"@pokeMutedSenderConfirmation": {
"placeholders": {
"sender": { "type": "String" },
"message": { "type": "String" }
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
@@ -1159,29 +1159,71 @@ abstract class AppL10n {
/// **'None'**
String get clientInfoNone;
/// No description provided for @pokeSnackBarClearAction.
/// No description provided for @pokeSettingsAction.
///
/// In en, this message translates to:
/// **'Clear'**
String get pokeSnackBarClearAction;
/// **'Poke notifications'**
String get pokeSettingsAction;
/// No description provided for @pokeSnackBarMoreIndicator.
/// No description provided for @pokeSettingsTitle.
///
/// In en, this message translates to:
/// **'...'**
String get pokeSnackBarMoreIndicator;
/// **'Poke notifications'**
String get pokeSettingsTitle;
/// No description provided for @pokeSnackBarIncomingNoMessage.
/// No description provided for @pokeSettingsEnableLabel.
///
/// In en, this message translates to:
/// **'{sender} pokes you'**
String pokeSnackBarIncomingNoMessage(String sender);
/// **'Notify me about pokes'**
String get pokeSettingsEnableLabel;
/// No description provided for @pokeSnackBarIncomingWithMessage.
/// No description provided for @pokeSettingsEnableDescription.
///
/// In en, this message translates to:
/// **'{sender} pokes you: {message}'**
String pokeSnackBarIncomingWithMessage(String sender, String message);
/// **'Show local notifications for incoming pokes when this is on.'**
String get pokeSettingsEnableDescription;
/// No description provided for @pokeSettingsMutedSendersHeader.
///
/// In en, this message translates to:
/// **'Muted senders'**
String get pokeSettingsMutedSendersHeader;
/// No description provided for @pokeSettingsMutedSendersEmpty.
///
/// In en, this message translates to:
/// **'No muted poke senders.'**
String get pokeSettingsMutedSendersEmpty;
/// No description provided for @pokeSettingsMutedSenderLabel.
///
/// In en, this message translates to:
/// **'Client ID {senderId}'**
String pokeSettingsMutedSenderLabel(String senderId);
/// No description provided for @pokeSettingsUnmuteSenderAction.
///
/// In en, this message translates to:
/// **'Unmute'**
String get pokeSettingsUnmuteSenderAction;
/// No description provided for @pokeOverflowMutePrompt.
///
/// In en, this message translates to:
/// **'Repeated pokes from {sender} were suppressed. Mute this sender?'**
String pokeOverflowMutePrompt(String sender);
/// No description provided for @pokeOverflowMuteAction.
///
/// In en, this message translates to:
/// **'Mute'**
String get pokeOverflowMuteAction;
/// No description provided for @pokeMutedSenderConfirmation.
///
/// In en, this message translates to:
/// **'Muted pokes from {sender}'**
String pokeMutedSenderConfirmation(String sender);
/// No description provided for @pokeHistorySelfNoMessage.
///
@@ -590,19 +590,43 @@ class AppL10nEn extends AppL10n {
String get clientInfoNone => 'None';
@override
String get pokeSnackBarClearAction => 'Clear';
String get pokeSettingsAction => 'Poke notifications';
@override
String get pokeSnackBarMoreIndicator => '...';
String get pokeSettingsTitle => 'Poke notifications';
@override
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender pokes you';
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 pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender pokes you: $message';
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
@@ -577,19 +577,42 @@ class AppL10nZh extends AppL10n {
String get clientInfoNone => '';
@override
String get pokeSnackBarClearAction => '清除';
String get pokeSettingsAction => '戳一戳通知';
@override
String get pokeSnackBarMoreIndicator => '...';
String get pokeSettingsTitle => '戳一戳通知';
@override
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender 戳了你一下';
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 pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender 戳了你一下:$message';
String get pokeSettingsUnmuteSenderAction => '取消静音';
@override
String pokeOverflowMutePrompt(String sender) {
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
}
@override
String get pokeOverflowMuteAction => '静音';
@override
String pokeMutedSenderConfirmation(String sender) {
return '已静音来自 $sender 的戳一戳';
}
@override
+144 -168
View File
@@ -22,14 +22,19 @@ import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
import 'services/app_bootstrap.dart';
import 'services/audio_lifecycle_service.dart';
import 'services/ios_audio_session_controller.dart';
import 'services/channel_join_error_mapper.dart';
import 'services/connection_phase_state.dart';
import 'services/hard_mute_owners.dart';
import 'services/ios_permissions_service.dart';
import 'services/macos_permissions_service.dart';
import 'services/poke_notification_service.dart';
import 'services/poke_preferences_service.dart';
import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart';
import 'services/ui_preferences_service.dart';
import 'services/voice_join_ordering.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'src/rust/lib.dart' as rust_err;
@@ -41,6 +46,7 @@ import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/poke_notification_settings.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
@@ -87,7 +93,10 @@ String _kAppVersion = appSemverBaseline;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await RustLib.init();
unawaited(wireStorage());
unawaited(() async {
await wireStorage();
await wireCache();
}());
unawaited(wireConnectivity());
wireAudioLifecycle();
await configureBundledVadModels();
@@ -159,10 +168,7 @@ class _ChanoraAppState extends State<ChanoraApp> {
supportedLocales: AppL10n.supportedLocales,
home: Stack(
children: [
_BetaHome(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
_BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode),
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
],
),
@@ -200,6 +206,19 @@ extension on ThemeMode {
}
}
bool isPokeSenderActiveChat({
required bool chatOpen,
required rust.BridgeMessageTarget? inlineChatTarget,
required BigInt senderId,
}) {
if (!chatOpen) return false;
return switch (inlineChatTarget) {
rust.BridgeMessageTarget_Poke(:final field0) ||
rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId,
_ => false,
};
}
class ChanoraThemeModeMenu extends StatelessWidget {
const ChanoraThemeModeMenu({
super.key,
@@ -300,18 +319,6 @@ class _BetaHome extends StatefulWidget {
State<_BetaHome> createState() => _BetaHomeState();
}
class _ReceivedPoke {
const _ReceivedPoke({
required this.senderName,
required this.message,
required this.receivedAt,
});
final String senderName;
final String message;
final DateTime receivedAt;
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
@@ -338,8 +345,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
bool _inChannel = false;
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
bool _hardMute = false;
bool _hardMuteByPermission = false;
bool _hardMuteByTalkPower = false;
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
bool get _hardMuteByPermission => _hardMuteOwners.permission;
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
bool _permissionHardMuteClearInFlight = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
@@ -409,11 +417,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeSnackBarVisible = false;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
// first voice_join attempt. On non-Android hosts the service
@@ -428,6 +431,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// MethodChannel.
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
final PokeNotificationService _pokeNotifications = PokeNotificationService();
final PokePreferencesService _pokePreferences = PokePreferencesService();
late final Future<void> _pokePreferencesReady;
@override
void initState() {
@@ -461,6 +467,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_onMacOSPttCapabilityChanged,
);
_macOSPermissions.checkInitialStates();
unawaited(_pokeNotifications.init());
_pokePreferencesReady = _pokePreferences.load();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup());
});
@@ -589,8 +597,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setHardMute(muted: false);
if (!mounted || !_hardMuteByPermission) return;
setState(() {
_hardMute = false;
_hardMuteByPermission = false;
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
_hardMute = _hardMuteOwners.effective;
});
} catch (e) {
if (!mounted) return;
@@ -740,6 +748,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_reconnectAttempt = null;
_reconnectDelay = null;
});
unawaited(iosAudioSessionController.activate());
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true));
case rust.BridgeEvent_Lost(:final reason):
_recordUiDiagnostic('connection', 'lost: $reason');
@@ -765,8 +774,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
});
case rust.BridgeEvent_AudioStarted():
unawaited(iosAudioSessionController.activate());
_ensureStatsTimer();
case rust.BridgeEvent_AudioStopped():
unawaited(iosAudioSessionController.deactivate());
_statsTimer?.cancel();
_statsTimer = null;
case rust.BridgeEvent_PttCapability(
@@ -795,8 +806,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_voiceStateInitialized = true;
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
if (!mute) _hardMuteByPermission = false;
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
_hardMute = _hardMuteOwners.effective;
_releaseTailMs = releaseTailMs;
_currentVoiceChannelId = currentChannelId;
_pendingVoiceChannelId = pendingTargetChannelId;
@@ -862,10 +873,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
:final senderName,
:final message,
:final target,
:final pokeStrength,
):
// Skip echo of self-sent messages (already added locally).
if (senderId == _snapshot?.ownClientId) return;
final isPoke = target is rust.BridgeMessageTarget_Poke;
// Skip echo of self-sent non-poke messages (already added locally).
if (!isPoke && senderId == _snapshot?.ownClientId) return;
final receivedAt = DateTime.now();
setState(() {
_appendChatEntryUnlocked(
@@ -880,10 +892,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
});
if (isPoke) {
_showPokeSnackBar(
unawaited(
_handleIncomingPoke(
senderId: senderId,
senderName: senderName,
message: message,
receivedAt: receivedAt,
strength: pokeStrength,
),
);
return;
}
@@ -1083,7 +1098,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_nickCtl.dispose();
_passwordCtl.dispose();
_chatFeedRevision.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
@@ -1100,6 +1114,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_androidPermissions.stop();
_iosPermissions.stop();
_macOSPermissions.stop();
_pokePreferences.dispose();
super.dispose();
}
@@ -1133,7 +1148,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
if (accessState == MacOSLocalNetworkState.denied) {
if (!mounted) return;
setState(() { _phase = ConnectionPhase.idle; });
setState(() {
_phase = ConnectionPhase.idle;
});
_showLocalNetworkDeniedSnackBar();
return;
}
@@ -1145,6 +1162,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_chatMessages.clear();
});
_releaseFocusedPttIfHeld();
await iosAudioSessionController.activate();
try {
final snap = await rust.connect(
host: (host ?? _hostCtl.text).trim(),
@@ -1268,8 +1286,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setHardMute(muted: true);
if (mounted) {
setState(() {
_hardMute = true;
_hardMuteByPermission = true;
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
_hardMute = _hardMuteOwners.effective;
});
}
} catch (_) {
@@ -1284,12 +1302,23 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setHardMute(muted: false);
if (mounted) {
setState(() {
_hardMute = false;
_hardMuteByPermission = false;
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
_hardMute = _hardMuteOwners.effective;
});
}
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
await joinVoiceChannelWithIosAudioSession(
channelId: ch.id,
password: password ?? '',
voiceJoin: rust.voiceJoin,
activateIosAudioSession: iosAudioSessionController.activate,
deactivateIosAudioSession: iosAudioSessionController.deactivate,
// Server says we are already in the target channel: the user is
// still joined to a voice channel, so the iOS audio session must
// stay active. The catch below converts this rethrow into the
// success-on-already-joined branch.
isJoinSuccess: _isAlreadyInChannel,
);
if (!mounted) return;
setState(() {
_currentVoiceChannelId = ch.id;
@@ -1353,13 +1382,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final next = !_hardMute;
final previousInputMuted = _inputMuted;
final previousHardMute = _hardMute;
final previousPermissionMute = _hardMuteByPermission;
final previousHardMuteOwners = _hardMuteOwners;
setState(() {
_inputMuted = next;
_hardMute = next;
if (next) {
_hardMuteByPermission = false;
}
_hardMuteOwners = _hardMuteOwners.copyWith(
manual: next,
permission: next ? false : null,
);
_hardMute = _hardMuteOwners.effective;
});
try {
// Hard-mute is two coordinated effects:
@@ -1378,7 +1408,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() {
_inputMuted = previousInputMuted;
_hardMute = previousHardMute;
_hardMuteByPermission = previousPermissionMute;
_hardMuteOwners = previousHardMuteOwners;
});
_showUiError('hard mute', e);
}
@@ -1514,6 +1544,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
}
Future<void> _onOpenPokeSettings() async {
await _pokePreferences.load();
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) =>
PokeNotificationSettingsDialog(preferences: _pokePreferences),
);
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
// Same pattern as _onAddCurrentBookmark: route the dialog
// through a dedicated StatefulWidget so its
@@ -1648,8 +1688,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_inputMuted = false;
_outputMuted = false;
_hardMute = false;
_hardMuteByPermission = false;
_hardMuteByTalkPower = false;
_hardMuteOwners = const HardMuteOwners();
_inChannel = false;
_currentVoiceChannelId = null;
_pendingVoiceChannelId = null;
@@ -1799,9 +1838,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _inlineChatTarget == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
),
SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)),
);
});
}
@@ -1822,52 +1859,68 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
}
void _showPokeSnackBar({
Future<void> _handleIncomingPoke({
required BigInt senderId,
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
required rust.BridgePokeStrength? strength,
}) async {
if (senderId == _snapshot?.ownClientId) return;
await _pokePreferencesReady;
if (!_pokePreferences.pokesEnabled.value) return;
if (_pokePreferences.isMuted(senderId)) return;
final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed;
if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) {
_showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName);
}
if (_isPokeSenderActiveChat(senderId)) return;
await _pokeNotifications.show(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
senderId: senderId,
strength: pokeStrength,
);
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
bool _isPokeSenderActiveChat(BigInt senderId) {
return isPokeSenderActiveChat(
chatOpen: _chatOpen,
inlineChatTarget: _inlineChatTarget,
senderId: senderId,
);
}
void _showPokeOverflowMutePrompt({
required BigInt senderId,
required String senderName,
}) {
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
duration: const Duration(seconds: 8),
content: Text(
l10n.pokeOverflowMutePrompt(senderName),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
label: l10n.pokeOverflowMuteAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
unawaited(_pokePreferences.muteSender(senderId));
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
content: Text(l10n.pokeMutedSenderConfirmation(senderName)),
),
);
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
@@ -1875,7 +1928,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@@ -2080,8 +2132,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
final talkPowerEpoch = _connectionEpoch;
_hardMuteByTalkPower = true;
_hardMute = true;
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
_hardMute = _hardMuteOwners.effective;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
_connectionEpoch != talkPowerEpoch ||
@@ -2094,15 +2146,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
final talkPowerEpoch = _connectionEpoch;
_hardMuteByTalkPower = false;
if (!_hardMuteByPermission) {
_hardMute = false;
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
_hardMute = _hardMuteOwners.effective;
if (!_hardMuteOwners.effective) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
_connectionEpoch != talkPowerEpoch ||
!_serverReachable ||
_hardMuteByTalkPower ||
_hardMuteByPermission) {
_hardMuteOwners.effective) {
return;
}
unawaited(rust.setHardMute(muted: false));
@@ -2373,6 +2424,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
themeMode: widget.themeMode,
onThemeModeChanged: widget.onThemeModeChanged,
),
IconButton(
tooltip: l10n.pokeSettingsAction,
icon: const Icon(Icons.notifications_outlined),
onPressed: () => unawaited(_onOpenPokeSettings()),
),
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
@@ -2852,83 +2908,3 @@ class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
);
}
}
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
);
}
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}
@@ -15,15 +15,22 @@ 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,
@@ -121,16 +128,50 @@ Future<void> _wireStorageImpl() async {
}
}
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) {
@@ -0,0 +1,31 @@
class HardMuteOwners {
const HardMuteOwners({
this.manual = false,
this.permission = false,
this.talkPower = false,
});
final bool manual;
final bool permission;
final bool talkPower;
bool get effective => manual || permission || talkPower;
HardMuteOwners withBridgeManualMute(bool muted) {
return copyWith(
manual: muted && (manual || !permission && !talkPower),
);
}
HardMuteOwners copyWith({
bool? manual,
bool? permission,
bool? talkPower,
}) {
return HardMuteOwners(
manual: manual ?? this.manual,
permission: permission ?? this.permission,
talkPower: talkPower ?? this.talkPower,
);
}
}
@@ -0,0 +1,66 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
///
/// The Swift `AppDelegate` configures the session to `.ambient` at
/// launch and leaves it inactive. The session is only switched to
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
/// voice channel is actually active. This controller is the Dart
/// side of that contract — call [activate] before the Rust engine
/// starts VoiceProcessingIO and [deactivate] on
/// `BridgeEvent::AudioStopped` or failed joins.
///
/// On non-iOS platforms both methods are no-ops; the platforms
/// handle their own session lifecycle elsewhere (Android via
/// `AndroidAudioLifecycleController`, macOS via
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
class IosAudioSessionController {
IosAudioSessionController({
MethodChannel? channel,
bool? isIos,
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
_isIos = isIos ?? Platform.isIOS;
final MethodChannel _channel;
final bool _isIos;
Future<void> activate() async {
if (!_isIos) return;
try {
await _channel.invokeMethod<void>('activateVoiceSession');
} on PlatformException {
// Swift side logs the failure via NSLog; surfacing the
// exception to the event handler would be noise. The Rust
// engine remains alive and will produce silence until the
// next route change or a manual leave/rejoin.
} on MissingPluginException {
// Test hosts and mispackaged builds may not have registered
// the iOS channel. Keep event dispatch alive rather than
// surfacing an unhandled async error.
}
}
Future<void> deactivate() async {
if (!_isIos) return;
try {
await _channel.invokeMethod<void>('deactivateVoiceSession');
} on PlatformException {
// Same rationale as activate(): the Swift side logs.
// Worst case the session stays in .playAndRecord until the
// app is backgrounded — at which point iOS reclaims the
// session automatically.
} on MissingPluginException {
// Same rationale as activate(): missing channel should not
// break bridge event handling.
}
}
}
/// Default singleton used by [main.dart] event dispatch. Tests
/// should construct their own [IosAudioSessionController] with a
/// mocked channel rather than mutating this instance.
final iosAudioSessionController = IosAudioSessionController();
@@ -0,0 +1,179 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../src/rust/api.dart' as rust;
class PokeNotificationService {
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
static const _strongAndroidChannelId = 'chanora_pokes_strong_v1';
static const _defaultAndroidChannelId = 'chanora_pokes_default_v1';
static const _groupKey = 'chanora.pokes';
static const _darwinThreadId = 'chanora.pokes';
static const _windowsHeader = WindowsHeader(
id: 'chanora.pokes',
title: 'Pokes',
arguments: 'pokes',
);
static const _windowsAppUserModelId = 'Chanora.Client';
static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E';
final FlutterLocalNotificationsPlugin _notifications;
bool _initialized = false;
Future<void> init() async {
if (_initialized) return;
await _notifications.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_chanora_notification'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
requestSoundPermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
defaultPresentSound: false,
),
macOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
requestSoundPermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
defaultPresentSound: false,
),
linux: LinuxInitializationSettings(
defaultActionName: 'Open',
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
defaultSuppressSound: true,
),
windows: WindowsInitializationSettings(
appName: 'Chanora',
appUserModelId: _windowsAppUserModelId,
guid: _windowsGuid,
),
),
);
_initialized = true;
}
Future<bool> requestPermission() async {
await init();
if (kIsWeb) return true;
final android = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (android != null) {
return await android.requestNotificationsPermission() ?? true;
}
final ios = _notifications
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (ios != null) {
return await ios.requestPermissions(alert: true, badge: true) ?? false;
}
final macOS = _notifications
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>();
if (macOS != null) {
return await macOS.requestPermissions(alert: true, badge: true) ?? false;
}
return true;
}
Future<void> show({
required String senderName,
required String message,
required BigInt senderId,
required rust.BridgePokeStrength strength,
}) async {
await init();
final permitted = await requestPermission();
if (!permitted) return;
final trimmedMessage = message.trim();
final body = trimmedMessage.isEmpty
? '$senderName pokes you'
: trimmedMessage;
await _notifications.show(
id: senderId.toUnsigned(31).toInt(),
title: 'Poke from $senderName',
body: body,
notificationDetails: NotificationDetails(
android: _androidDetails(strength),
iOS: _darwinDetails(strength),
macOS: _darwinDetails(strength),
linux: _linuxDetails(strength),
windows: _windowsDetails(strength),
),
payload: 'poke:$senderId',
);
}
AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) {
final isStrong = strength == rust.BridgePokeStrength.strong;
return AndroidNotificationDetails(
isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId,
isStrong ? 'Pokes' : 'Pokes (quiet)',
channelDescription: 'TeamSpeak poke notifications',
importance: isStrong ? Importance.max : Importance.defaultImportance,
priority: isStrong ? Priority.high : Priority.defaultPriority,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
playSound: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
silent: true,
groupKey: _groupKey,
category: AndroidNotificationCategory.message,
visibility: NotificationVisibility.private,
);
}
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
return DarwinNotificationDetails(
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
presentSound: false,
threadIdentifier: _darwinThreadId,
interruptionLevel: switch (strength) {
rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive,
rust.BridgePokeStrength.suppressed => InterruptionLevel.active,
rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive,
},
);
}
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
return LinuxNotificationDetails(
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
suppressSound: true,
urgency: switch (strength) {
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal,
rust.BridgePokeStrength.suppressedOverflow =>
LinuxNotificationUrgency.low,
},
);
}
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
return WindowsNotificationDetails(
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
audio: WindowsNotificationAudio.silent(),
header: _windowsHeader,
scenario: strength == rust.BridgePokeStrength.strong
? WindowsNotificationScenario.urgent
: null,
duration: strength == rust.BridgePokeStrength.strong
? WindowsNotificationDuration.long
: WindowsNotificationDuration.short,
);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PokePreferencesService {
static const _enabledKey = 'pokes.enabled';
static const _mutedSendersKey = 'pokes.muted_senders';
final ValueNotifier<bool> _pokesEnabled = ValueNotifier<bool>(true);
final ValueNotifier<Set<BigInt>> _mutedSenders = ValueNotifier<Set<BigInt>>(
const <BigInt>{},
);
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
_mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const [])
.map(BigInt.parse)
.toSet();
}
Future<void> setPokesEnabled(bool enabled) async {
_pokesEnabled.value = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enabledKey, enabled);
}
Future<void> muteSender(BigInt senderId) async {
if (_mutedSenders.value.contains(senderId)) return;
_mutedSenders.value = {..._mutedSenders.value, senderId};
await _saveMutedSenders();
}
Future<void> unmuteSender(BigInt senderId) async {
if (!_mutedSenders.value.contains(senderId)) return;
_mutedSenders.value = _mutedSenders.value
.where((mutedSender) => mutedSender != senderId)
.toSet();
await _saveMutedSenders();
}
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
Future<void> _saveMutedSenders() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_mutedSendersKey,
_mutedSenders.value.map((senderId) => senderId.toString()).toList(),
);
}
void dispose() {
_pokesEnabled.dispose();
_mutedSenders.dispose();
}
}
@@ -0,0 +1,36 @@
typedef VoiceJoinCallback = Future<void> Function({
required BigInt channelId,
required String password,
});
typedef IosVoiceSessionActivation = Future<void> Function();
typedef IosVoiceSessionDeactivation = Future<void> Function();
/// Predicate used to recognise `voiceJoin` errors that the caller treats as a
/// successful join outcome (e.g. the server replied "already in channel").
///
/// When this returns `true` for a thrown error, the iOS audio session is kept
/// active because the user is still considered joined to the channel. The
/// error is still rethrown so the caller can run its success-on-already-joined
/// branch and update local state.
typedef VoiceJoinSuccessPredicate = bool Function(Object error);
Future<void> joinVoiceChannelWithIosAudioSession({
required BigInt channelId,
required String password,
required VoiceJoinCallback voiceJoin,
required IosVoiceSessionActivation activateIosAudioSession,
required IosVoiceSessionDeactivation deactivateIosAudioSession,
VoiceJoinSuccessPredicate? isJoinSuccess,
}) async {
await activateIosAudioSession();
try {
await voiceJoin(channelId: channelId, password: password);
} catch (e) {
if (isJoinSuccess != null && isJoinSuccess(e)) {
rethrow;
}
await deactivateIosAudioSession();
rethrow;
}
}
+113 -9
View File
@@ -11,7 +11,7 @@ part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -226,6 +226,29 @@ String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
Future<void> initStorage({required String dir}) =>
RustLib.instance.api.crateApiInitStorage(dir: dir);
/// Configure the bridge blob cache root.
Future<void> initCache({required String dir}) =>
RustLib.instance.api.crateApiInitCache(dir: dir);
/// Resolve avatar bytes through the bridge.
Future<Uint8List?> downloadAvatar({
required String avatarHash,
required String clientUid,
}) => RustLib.instance.api.crateApiDownloadAvatar(
avatarHash: avatarHash,
clientUid: clientUid,
);
/// Resolve icon bytes through the bridge.
Future<Uint8List?> downloadIcon({required BigInt iconId}) =>
RustLib.instance.api.crateApiDownloadIcon(iconId: iconId);
/// Purge cached protocol-owned assets.
Future<void> clearFileCache() => RustLib.instance.api.crateApiClearFileCache();
/// Report the configured file-cache size.
Future<BigInt> fileCacheSize() => RustLib.instance.api.crateApiFileCacheSize();
/// List persisted bookmarks.
Future<List<BridgeBookmark>> listBookmarks() =>
RustLib.instance.api.crateApiListBookmarks();
@@ -263,7 +286,8 @@ Future<BridgeAudioStats> audioStats() =>
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels or the session is dropped.
/// when the Dart subscriber cancels, the session is dropped, or
/// the session becomes persistently unavailable.
Stream<double> inputLevelStream() =>
RustLib.instance.api.crateApiInputLevelStream();
@@ -1209,6 +1233,9 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Target scope (server/channel/private/poke).
required BridgeMessageTarget target,
/// Poke notification strength, present only for poke messages.
BridgePokeStrength? pokeStrength,
}) = BridgeEvent_ChatMessage;
/// Human-readable server activity surfaced from protocol bookkeeping events.
@@ -1219,59 +1246,124 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Audio route changed (speaker/earpiece/BT/wired).
const factory BridgeEvent.audioRouteChanged({
/// New audio output route.
required BridgeAudioRoute route,
}) = BridgeEvent_AudioRouteChanged;
/// A client moved to a different channel.
const factory BridgeEvent.clientMoved({
/// Unique client identifier.
required BigInt clientId,
/// Destination channel.
required BigInt newChannelId,
}) = BridgeEvent_ClientMoved;
/// A new client connected.
const factory BridgeEvent.clientJoined({
/// Unique client identifier.
required BigInt clientId,
/// Channel the client joined.
required BigInt channelId,
/// Display nickname.
required String name,
/// Microphone muted state.
required bool inputMuted,
/// Speaker muted state.
required bool outputMuted,
/// True for server query (bot) clients.
required bool isServerQuery,
/// Client's talk power value.
required int talkPower,
/// Whether the server granted temporary talk power.
required bool talkPowerGranted,
}) = BridgeEvent_ClientJoined;
/// A client disconnected.
const factory BridgeEvent.clientLeft({
/// Unique client identifier.
required BigInt clientId,
/// Display nickname at time of disconnect.
required String name,
}) = BridgeEvent_ClientLeft;
/// Client properties changed.
const factory BridgeEvent.clientUpdated({
/// Unique client identifier.
required BigInt clientId,
/// Microphone muted state.
required bool inputMuted,
/// Speaker muted state.
required bool outputMuted,
/// True for server query (bot) clients.
required bool isServerQuery,
/// Client's talk power value.
required int talkPower,
/// Whether the server granted temporary talk power.
required bool talkPowerGranted,
}) = BridgeEvent_ClientUpdated;
/// A new channel appeared.
const factory BridgeEvent.channelAdded({
/// Unique channel identifier.
required BigInt id,
/// Parent channel ID.
required BigInt parent,
/// Channel name.
required String name,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
required PlatformInt64 order,
/// Whether the channel requires a password.
required bool hasPassword,
/// Talk power required to speak; `None` means no restriction.
int? neededTalkPower,
}) = BridgeEvent_ChannelAdded;
const factory BridgeEvent.channelRemoved({required BigInt id}) =
BridgeEvent_ChannelRemoved;
const factory BridgeEvent.channelUpdated({
/// A channel was deleted.
const factory BridgeEvent.channelRemoved({
/// Channel identifier.
required BigInt id,
}) = BridgeEvent_ChannelRemoved;
/// Channel properties changed.
const factory BridgeEvent.channelUpdated({
/// Unique channel identifier.
required BigInt id,
/// Channel name.
required String name,
/// Whether the channel requires a password.
required bool hasPassword,
/// Talk power required to speak; `None` means no restriction.
int? neededTalkPower,
}) = BridgeEvent_ChannelUpdated;
}
/// Bridge iOS voice-processing mode.
enum BridgeIosVoiceProcessingMode {
/// Shipping VPIO path.
/// Apple VoiceProcessingIO path.
platformVoiceProcessing,
/// Experimental Sonora path.
sonoraExperimental,
}
@freezed
@@ -1306,6 +1398,18 @@ enum BridgeNetworkState {
offline,
}
/// Bridge poke notification strength.
enum BridgePokeStrength {
/// Poke should be surfaced at full strength.
strong,
/// Poke is rate-limited but below overflow severity.
suppressed,
/// Poke remains suppressed after repeated suppressed pokes.
suppressedOverflow,
}
/// Persisted PTT binding display state for the UI.
class BridgePttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
@@ -173,7 +173,7 @@ return channelUpdated(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -186,7 +186,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
@@ -213,7 +213,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -226,7 +226,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity():
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined():
@@ -249,7 +249,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -262,7 +262,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
@@ -929,7 +929,7 @@ as PermissionStateKind,
class BridgeEvent_ChatMessage extends BridgeEvent {
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target, this.pokeStrength}): super._();
/// Client id of the sender.
@@ -940,6 +940,8 @@ class BridgeEvent_ChatMessage extends BridgeEvent {
final String message;
/// Target scope (server/channel/private/poke).
final BridgeMessageTarget target;
/// Poke notification strength, present only for poke messages.
final BridgePokeStrength? pokeStrength;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@@ -951,16 +953,16 @@ $BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$Brid
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target));
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength));
}
@override
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength);
@override
String toString() {
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)';
}
@@ -971,7 +973,7 @@ abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEv
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
@useResult
$Res call({
BigInt senderId, String senderName, String message, BridgeMessageTarget target
BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength
});
@@ -988,13 +990,14 @@ class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,Object? pokeStrength = freezed,}) {
return _then(BridgeEvent_ChatMessage(
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
as BridgeMessageTarget,
as BridgeMessageTarget,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable
as BridgePokeStrength?,
));
}
@@ -1084,6 +1087,7 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
/// New audio output route.
final BridgeAudioRoute route;
/// Create a copy of BridgeEvent
@@ -1150,7 +1154,9 @@ class BridgeEvent_ClientMoved extends BridgeEvent {
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Destination channel.
final BigInt newChannelId;
/// Create a copy of BridgeEvent
@@ -1218,13 +1224,21 @@ class BridgeEvent_ClientJoined extends BridgeEvent {
const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Channel the client joined.
final BigInt channelId;
/// Display nickname.
final String name;
/// Microphone muted state.
final bool inputMuted;
/// Speaker muted state.
final bool outputMuted;
/// True for server query (bot) clients.
final bool isServerQuery;
/// Client's talk power value.
final int talkPower;
/// Whether the server granted temporary talk power.
final bool talkPowerGranted;
/// Create a copy of BridgeEvent
@@ -1298,7 +1312,9 @@ class BridgeEvent_ClientLeft extends BridgeEvent {
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Display nickname at time of disconnect.
final String name;
/// Create a copy of BridgeEvent
@@ -1366,11 +1382,17 @@ class BridgeEvent_ClientUpdated extends BridgeEvent {
const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Microphone muted state.
final bool inputMuted;
/// Speaker muted state.
final bool outputMuted;
/// True for server query (bot) clients.
final bool isServerQuery;
/// Client's talk power value.
final int talkPower;
/// Whether the server granted temporary talk power.
final bool talkPowerGranted;
/// Create a copy of BridgeEvent
@@ -1442,11 +1464,18 @@ class BridgeEvent_ChannelAdded extends BridgeEvent {
const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._();
/// Unique channel identifier.
final BigInt id;
/// Parent channel ID.
final BigInt parent;
/// Channel name.
final String name;
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
final PlatformInt64 order;
/// Whether the channel requires a password.
final bool hasPassword;
/// Talk power required to speak; `None` means no restriction.
final int? neededTalkPower;
/// Create a copy of BridgeEvent
@@ -1518,6 +1547,7 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent {
const BridgeEvent_ChannelRemoved({required this.id}): super._();
/// Channel identifier.
final BigInt id;
/// Create a copy of BridgeEvent
@@ -1584,9 +1614,13 @@ class BridgeEvent_ChannelUpdated extends BridgeEvent {
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
/// Unique channel identifier.
final BigInt id;
/// Channel name.
final String name;
/// Whether the channel requires a password.
final bool hasPassword;
/// Talk power required to speak; `None` means no restriction.
final int? neededTalkPower;
/// Create a copy of BridgeEvent
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -20394775;
int get rustContentHash => 635684021;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiBridgeInit();
Future<void> crateApiClearFileCache();
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
Future<BridgeSnapshot> crateApiConnect({
@@ -99,12 +101,21 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiDisconnect();
Future<Uint8List?> crateApiDownloadAvatar({
required String avatarHash,
required String clientUid,
});
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId});
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
Stream<BridgeEvent> crateApiEventsStream();
String crateApiExportDiagnostics();
Future<BigInt> crateApiFileCacheSize();
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
Future<BridgePttBinding> crateApiGetPttBinding();
@@ -121,6 +132,8 @@ abstract class RustLibApi extends BaseApi {
void crateApiHandleRouteChange({required BridgeAudioRoute route});
Future<void> crateApiInitCache({required String dir});
Future<void> crateApiInitStorage({required String dir});
Stream<double> crateApiInputLevelStream();
@@ -320,6 +333,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
const TaskConstMeta(debugName: "bridge_init", argNames: []);
@override
Future<void> crateApiClearFileCache() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 5,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiClearFileCacheConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiClearFileCacheConstMeta =>
const TaskConstMeta(debugName: "clear_file_cache", argNames: []);
@override
Future<BridgeClientProfile> crateApiClientProfile({
required BigInt clientId,
@@ -332,7 +372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 5,
funcId: 6,
port: port_,
);
},
@@ -366,7 +406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 6,
funcId: 7,
port: port_,
);
},
@@ -396,7 +436,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 7,
funcId: 8,
port: port_,
);
},
@@ -423,7 +463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 8,
funcId: 9,
port: port_,
);
},
@@ -441,6 +481,68 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiDisconnectConstMeta =>
const TaskConstMeta(debugName: "disconnect", argNames: []);
@override
Future<Uint8List?> crateApiDownloadAvatar({
required String avatarHash,
required String clientUid,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(avatarHash, serializer);
sse_encode_String(clientUid, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 10,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiDownloadAvatarConstMeta,
argValues: [avatarHash, clientUid],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiDownloadAvatarConstMeta => const TaskConstMeta(
debugName: "download_avatar",
argNames: ["avatarHash", "clientUid"],
);
@override
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_64(iconId, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 11,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiDownloadIconConstMeta,
argValues: [iconId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiDownloadIconConstMeta =>
const TaskConstMeta(debugName: "download_icon", argNames: ["iconId"]);
@override
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
return handler.executeNormal(
@@ -451,7 +553,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 9,
funcId: 12,
port: port_,
);
},
@@ -484,7 +586,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 10,
funcId: 13,
port: port_,
);
},
@@ -510,7 +612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -526,6 +628,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiExportDiagnosticsConstMeta =>
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
@override
Future<BigInt> crateApiFileCacheSize() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 15,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_u_64,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiFileCacheSizeConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiFileCacheSizeConstMeta =>
const TaskConstMeta(debugName: "file_cache_size", argNames: []);
@override
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
return handler.executeNormal(
@@ -535,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
funcId: 16,
port: port_,
);
},
@@ -565,7 +694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 13,
funcId: 17,
port: port_,
);
},
@@ -592,7 +721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 14,
funcId: 18,
port: port_,
);
},
@@ -619,7 +748,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 15,
funcId: 19,
port: port_,
);
},
@@ -643,7 +772,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -666,7 +795,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(shouldResume, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -692,7 +821,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(routeClass, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -718,7 +847,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -736,6 +865,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["route"],
);
@override
Future<void> crateApiInitCache({required String dir}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(dir, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiInitCacheConstMeta,
argValues: [dir],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiInitCacheConstMeta =>
const TaskConstMeta(debugName: "init_cache", argNames: ["dir"]);
@override
Future<void> crateApiInitStorage({required String dir}) {
return handler.executeNormal(
@@ -746,7 +903,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
funcId: 25,
port: port_,
);
},
@@ -776,7 +933,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
funcId: 26,
port: port_,
);
},
@@ -805,7 +962,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 27,
port: port_,
);
},
@@ -832,7 +989,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 23,
funcId: 28,
port: port_,
);
},
@@ -859,7 +1016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
funcId: 29,
port: port_,
);
},
@@ -883,7 +1040,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -913,7 +1070,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
funcId: 31,
port: port_,
);
},
@@ -943,7 +1100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 32,
port: port_,
);
},
@@ -970,7 +1127,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 28,
funcId: 33,
port: port_,
);
},
@@ -995,7 +1152,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1028,7 +1185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 30,
funcId: 35,
port: port_,
);
},
@@ -1055,7 +1212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1089,7 +1246,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 37,
port: port_,
);
},
@@ -1124,7 +1281,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 38,
port: port_,
);
},
@@ -1154,7 +1311,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 39,
port: port_,
);
},
@@ -1182,7 +1339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 35,
funcId: 40,
port: port_,
);
},
@@ -1210,7 +1367,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 41,
port: port_,
);
},
@@ -1240,7 +1397,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 37,
funcId: 42,
port: port_,
);
},
@@ -1268,7 +1425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1294,7 +1451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 44,
port: port_,
);
},
@@ -1322,7 +1479,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 40,
funcId: 45,
port: port_,
);
},
@@ -1350,7 +1507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 41,
funcId: 46,
port: port_,
);
},
@@ -1378,7 +1535,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 42,
funcId: 47,
port: port_,
);
},
@@ -1410,7 +1567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 43,
funcId: 48,
port: port_,
);
},
@@ -1440,7 +1597,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 44,
funcId: 49,
port: port_,
);
},
@@ -1468,7 +1625,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 45,
funcId: 50,
port: port_,
);
},
@@ -1496,7 +1653,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 51,
port: port_,
);
},
@@ -1523,7 +1680,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 47,
funcId: 52,
port: port_,
);
},
@@ -1551,7 +1708,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 48,
funcId: 53,
port: port_,
);
},
@@ -1583,7 +1740,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 49,
funcId: 54,
port: port_,
);
},
@@ -1612,7 +1769,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 50,
funcId: 55,
port: port_,
);
},
@@ -1683,6 +1840,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dco_decode_bridge_message_target(raw);
}
@protected
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dco_decode_bridge_poke_strength(raw);
}
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
@@ -2007,6 +2170,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
senderName: dco_decode_String(raw[2]),
message: dco_decode_String(raw[3]),
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
pokeStrength: dco_decode_opt_box_autoadd_bridge_poke_strength(raw[5]),
);
case 11:
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
@@ -2098,6 +2262,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeNetworkState.values[raw as int];
}
@protected
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return BridgePokeStrength.values[raw as int];
}
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2234,6 +2404,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw == null ? null : dco_decode_String(raw);
}
@protected
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
dynamic raw,
) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null
? null
: dco_decode_box_autoadd_bridge_poke_strength(raw);
}
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
@@ -2267,6 +2447,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw == null ? null : dco_decode_box_autoadd_u_64(raw);
}
@protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_list_prim_u_8_strict(raw);
}
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2358,6 +2544,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_bridge_message_target(deserializer));
}
@protected
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_bridge_poke_strength(deserializer));
}
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -2809,11 +3003,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_target = sse_decode_box_autoadd_bridge_message_target(
deserializer,
);
var var_pokeStrength = sse_decode_opt_box_autoadd_bridge_poke_strength(
deserializer,
);
return BridgeEvent_ChatMessage(
senderId: var_senderId,
senderName: var_senderName,
message: var_message,
target: var_target,
pokeStrength: var_pokeStrength,
);
case 11:
var var_message = sse_decode_String(deserializer);
@@ -2941,6 +3139,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeNetworkState.values[inner];
}
@protected
BridgePokeStrength sse_decode_bridge_poke_strength(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var inner = sse_decode_i_32(deserializer);
return BridgePokeStrength.values[inner];
}
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3132,6 +3339,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_bridge_poke_strength(deserializer));
} else {
return null;
}
}
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
@@ -3192,6 +3412,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_list_prim_u_8_strict(deserializer));
} else {
return null;
}
}
@protected
PermissionStateKind sse_decode_permission_state_kind(
SseDeserializer deserializer,
@@ -3306,6 +3537,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bridge_message_target(self, serializer);
}
@protected
void sse_encode_box_autoadd_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bridge_poke_strength(self, serializer);
}
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
@@ -3644,12 +3884,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
senderName: final senderName,
message: final message,
target: final target,
pokeStrength: final pokeStrength,
):
sse_encode_i_32(10, serializer);
sse_encode_u_64(senderId, serializer);
sse_encode_String(senderName, serializer);
sse_encode_String(message, serializer);
sse_encode_box_autoadd_bridge_message_target(target, serializer);
sse_encode_opt_box_autoadd_bridge_poke_strength(
pokeStrength,
serializer,
);
case BridgeEvent_ServerActivity(message: final message):
sse_encode_i_32(11, serializer);
sse_encode_String(message, serializer);
@@ -3771,6 +4016,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(self.index, serializer);
}
@protected
void sse_encode_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.index, serializer);
}
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
@@ -3947,6 +4201,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_box_autoadd_bridge_poke_strength(
BridgePokeStrength? self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_bridge_poke_strength(self, serializer);
}
}
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
@@ -4003,6 +4270,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_list_prim_u_8_strict(
Uint8List? self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_list_prim_u_8_strict(self, serializer);
}
}
@protected
void sse_encode_permission_state_kind(
PermissionStateKind self,
@@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
@@ -120,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@@ -174,6 +180,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
dynamic raw,
);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@@ -190,6 +201,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@@ -240,6 +254,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -328,6 +347,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@@ -400,6 +424,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
@@ -418,6 +447,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
PermissionStateKind sse_decode_permission_state_kind(
SseDeserializer deserializer,
@@ -477,6 +509,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
@@ -588,6 +626,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
@@ -681,6 +725,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_poke_strength(
BridgePokeStrength? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
@@ -702,6 +752,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@protected
void sse_encode_opt_list_prim_u_8_strict(
Uint8List? self,
SseSerializer serializer,
);
@protected
void sse_encode_permission_state_kind(
PermissionStateKind self,
@@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
@@ -122,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@@ -176,6 +182,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
dynamic raw,
);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@@ -192,6 +203,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@@ -242,6 +256,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -330,6 +349,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@@ -402,6 +426,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
@@ -420,6 +449,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
PermissionStateKind sse_decode_permission_state_kind(
SseDeserializer deserializer,
@@ -479,6 +511,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
@@ -590,6 +628,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
@@ -683,6 +727,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_poke_strength(
BridgePokeStrength? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
@@ -704,6 +754,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@protected
void sse_encode_opt_list_prim_u_8_strict(
Uint8List? self,
SseSerializer serializer,
);
@protected
void sse_encode_permission_state_kind(
PermissionStateKind self,
@@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
typedef ChatMessageSender =
Future<void> Function({
required String message,
required rust.BridgeMessageTarget target,
});
/// One chat/activity message shown in the chat hub.
class ChatEntry {
/// Construct a chat entry.
@@ -497,7 +503,7 @@ String chatInputPlaceholder(
case rust.BridgeMessageTarget_Client():
return 'Message $clientName...';
case rust.BridgeMessageTarget_Poke():
return 'Poke message...';
return 'Poke message optional...';
}
}
@@ -513,6 +519,16 @@ bool canSendToChatTarget(
}
}
bool canSendChatMessage(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
String text,
) {
if (!canSendToChatTarget(target, currentChannelId)) return false;
if (target is rust.BridgeMessageTarget_Poke) return true;
return text.trim().isNotEmpty;
}
String? chatSendBlockedReason(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
@@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget {
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
this.sendChatMessage,
});
/// Chat target displayed by this detail view.
@@ -1101,6 +1118,9 @@ class ChatDetailView extends StatefulWidget {
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
/// Sends a chat message. Defaults to the Rust bridge send path.
final ChatMessageSender? sendChatMessage;
@override
State<ChatDetailView> createState() => _ChatDetailViewState();
}
@@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State<ChatDetailView> {
void _send() {
final text = _textCtl.text.trim();
if (text.isEmpty || !_canSend) return;
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
return;
}
_textCtl.clear();
unawaited(rust.sendChatMessage(message: text, target: widget.target));
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
unawaited(sendChatMessage(message: text, target: widget.target));
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
@@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
channelName: widget.channelName,
clientName: widget.clientName,
);
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
? 'Poke'
: 'Send';
return Column(
children: [
@@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
tooltip: sendTooltip,
),
],
),
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/poke_preferences_service.dart';
import 'voice_settings_controls.dart';
class PokeNotificationSettingsDialog extends StatelessWidget {
const PokeNotificationSettingsDialog({super.key, required this.preferences});
final PokePreferencesService preferences;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.pokeSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder<bool>(
valueListenable: preferences.pokesEnabled,
builder: (context, enabled, _) => SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(l10n.pokeSettingsEnableLabel),
subtitle: Text(l10n.pokeSettingsEnableDescription),
value: enabled,
onChanged: (value) => preferences.setPokesEnabled(value),
),
),
const Divider(height: 24),
VoiceSectionHeader(l10n.pokeSettingsMutedSendersHeader),
ValueListenableBuilder<Set<BigInt>>(
valueListenable: preferences.mutedSenders,
builder: (context, mutedSenders, _) {
final senders = mutedSenders.toList()..sort();
if (senders.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Text(
l10n.pokeSettingsMutedSendersEmpty,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final senderId in senders)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.notifications_off_outlined),
title: Text(
l10n.pokeSettingsMutedSenderLabel(
senderId.toString(),
),
),
trailing: TextButton(
onPressed: () => preferences.unmuteSender(senderId),
child: Text(l10n.pokeSettingsUnmuteSenderAction),
),
),
],
);
},
),
const SizedBox(height: 8),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
],
);
}
}
@@ -318,6 +318,15 @@ class _VoicePttButtonState extends State<VoicePttButton> {
playVoicePttHaptic(held);
}
@override
void dispose() {
if (_pressed) {
_pressed = false;
widget.onHeldChanged(false);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -629,6 +638,13 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
selected: _mode == rust.BridgeTransmitMode.continuous,
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
),
// Voice-activity transmit is only honoured by the engine on
// hosts that ship a Chanora-owned VAD pipeline (DEC-030:
// Windows + Linux desktop and Android). iOS / macOS rely
// on Apple VoiceProcessingIO and have no VAD bridge, so
// hiding the row prevents the UI from advertising a
// transmit mode the engine cannot honour.
if (voiceActivityTransmitAvailable)
_ModeRow(
label: l10n.voiceModeVoiceActivity,
icon: Icons.graphic_eq,
@@ -128,7 +128,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
VoiceSectionHeader(l10n.voiceModeLabel),
SegmentedButton<rust.BridgeTransmitMode>(
style: voiceSegmentedButtonStyle(theme),
segments: transmitModeSegments,
// DEC-030: hide the voice-activity segment on hosts
// that ship no Chanora-owned VAD pipeline (iOS,
// macOS, web).
segments: transmitModeSegmentsFor(
voiceActivityAvailable: voiceActivityTransmitAvailable,
),
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
@@ -1,3 +1,6 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../src/rust/api.dart' as rust;
@@ -10,7 +13,11 @@ ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
);
}
/// Transmit mode selector segments.
/// Transmit mode selector segments — full set, all three modes.
///
/// This list is kept stable for legacy call sites and tests; UI
/// surfaces that must respect DEC-030 platform gating should prefer
/// [transmitModeSegmentsFor] with [voiceActivityTransmitAvailable].
const transmitModeSegments = [
ButtonSegment(
value: rust.BridgeTransmitMode.ptt,
@@ -29,6 +36,43 @@ const transmitModeSegments = [
),
];
/// Transmit mode selector segments, optionally dropping the
/// voice-activity entry on hosts that do not ship a VAD pipeline.
///
/// Voice activity transmit is gated by [voiceActivityTransmitAvailable]
/// because the underlying VAD pipeline ships only on Windows, Linux, and
/// Android per DEC-030. Builds for unsupported platforms (iOS, macOS,
/// web) drop the VAD segment entirely so the UI never advertises a
/// transmit mode the engine cannot honour.
List<ButtonSegment<rust.BridgeTransmitMode>> transmitModeSegmentsFor({
required bool voiceActivityAvailable,
}) {
if (voiceActivityAvailable) return transmitModeSegments;
return const [
ButtonSegment(
value: rust.BridgeTransmitMode.ptt,
label: Text('PTT'),
icon: Icon(Icons.radio_button_checked, size: 14),
),
ButtonSegment(
value: rust.BridgeTransmitMode.continuous,
label: Text('Always'),
icon: Icon(Icons.podcasts, size: 14),
),
];
}
/// True when this host advertises VAD transmit per DEC-030.
///
/// The desktop Silero ONNX + WebRTC fallback ships on Windows and
/// Linux; the Android Oboe + WebRTC path covers Android; iOS uses the
/// Apple CoreML Silero VAD pipeline via `vad::apple_coreml`. macOS is
/// still gated until its VAD pipeline is confirmed.
bool get voiceActivityTransmitAvailable {
if (kIsWeb) return false;
return Platform.isWindows || Platform.isLinux || Platform.isAndroid || Platform.isIOS;
}
/// Android hardware/WebRTC selector segments.
const androidProcessingSegments = [
ButtonSegment(
@@ -42,6 +42,8 @@
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
<key>NSUserNotificationsUsageDescription</key>
<string>Chanora sends you a notification when another user pokes you.</string>
<key>NSBonjourServices</key>
<array>
<string>_ts3._tcp</string>
+72 -32
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "96.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "10.2.0"
args:
dependency: transitive
description:
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -197,10 +197,10 @@ packages:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.13"
fake_async:
dependency: transitive
description:
@@ -262,6 +262,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
url: "https://pub.dev"
source: hosted
version: "22.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
url: "https://pub.dev"
source: hosted
version: "8.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
url: "https://pub.dev"
source: hosted
version: "12.0.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -321,18 +361,18 @@ packages:
dependency: "direct main"
description:
name: haptic_kit
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
sha256: "457f825a3413be2651954639bed27bb2987570f75d90c4e8e1cb9be62db2e59d"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.0.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.2"
http:
dependency: transitive
description:
@@ -469,14 +509,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nm:
dependency: transitive
description:
@@ -489,10 +521,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
package_config:
dependency: transitive
description:
@@ -665,10 +697,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
url: "https://pub.dev"
source: hosted
version: "2.4.23"
version: "2.4.25"
shared_preferences_foundation:
dependency: transitive
description:
@@ -794,6 +826,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
timezone:
dependency: transitive
description:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
typed_data:
dependency: transitive
description:
@@ -814,10 +854,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.30"
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
@@ -926,10 +966,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.2.0"
version: "6.3.0"
xdg_directories:
dependency: transitive
description:
@@ -942,10 +982,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
yaml:
dependency: transitive
description:
@@ -955,5 +995,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.5 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+1
View File
@@ -75,6 +75,7 @@ dependencies:
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
haptic_kit: ^1.0.0
flutter_foreground_task: ^9.2.2
flutter_local_notifications: ^22.0.0
url_launcher: ^6.3.2
shared_preferences: ^2.5.5
share_plus: ^13.1.0
@@ -31,6 +31,18 @@ if [ ! -f "${BINARY}" ]; then
exit 1
fi
# Flutter Debug builds split user code into a sibling `<App>.debug.dylib`
# alongside a tiny launcher executable; the @_cdecl symbols live in the
# dylib. Release/Profile builds put everything in the main executable.
# Prefer the dylib when both exist so the check verifies the slice that
# actually carries the symbols.
EXECUTABLE_DIR=$(dirname "${BINARY}")
EXECUTABLE_NAME=$(basename "${BINARY}")
DEBUG_DYLIB="${EXECUTABLE_DIR}/${EXECUTABLE_NAME}.debug.dylib"
if [ -f "${DEBUG_DYLIB}" ]; then
BINARY="${DEBUG_DYLIB}"
fi
REQUIRED_SYMBOLS="
_chanora_silero_vad_create
_chanora_silero_vad_destroy
@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/hard_mute_owners.dart';
void main() {
group('HardMuteOwners', () {
test('manual mute survives talk-power block and restore', () {
const owners = HardMuteOwners(manual: true);
final blocked = owners.copyWith(talkPower: true);
expect(blocked.effective, isTrue);
final restored = blocked.copyWith(talkPower: false);
expect(restored.manual, isTrue);
expect(restored.talkPower, isFalse);
expect(restored.effective, isTrue);
});
test('effective mute is the union of independent owners', () {
expect(const HardMuteOwners().effective, isFalse);
expect(const HardMuteOwners(manual: true).effective, isTrue);
expect(const HardMuteOwners(permission: true).effective, isTrue);
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
});
test('bridge mute does not convert talk-power owner into manual owner', () {
const owners = HardMuteOwners(talkPower: true);
final synced = owners.withBridgeManualMute(true);
expect(synced.manual, isFalse);
expect(synced.talkPower, isTrue);
expect(synced.effective, isTrue);
});
});
}
@@ -0,0 +1,99 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('IosAudioSessionController', () {
const channel = MethodChannel(iosAudioSessionChannelName);
final messenger = TestDefaultBinaryMessengerBinding
.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('channel name matches Swift contract', () {
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
});
test('activate invokes activateVoiceSession on iOS', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return null;
});
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await controller.activate();
expect(calls.map((c) => c.method), ['activateVoiceSession']);
expect(calls.single.arguments, isNull);
});
test('deactivate invokes deactivateVoiceSession on iOS', () async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return null;
});
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await controller.deactivate();
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
expect(calls.single.arguments, isNull);
});
test('activate is a no-op on non-iOS platforms', () async {
var invoked = false;
messenger.setMockMethodCallHandler(channel, (call) async {
invoked = true;
return null;
});
final controller = IosAudioSessionController(
channel: channel,
isIos: false,
);
await controller.activate();
await controller.deactivate();
expect(invoked, isFalse);
});
test('activate swallows PlatformException so engine keeps running',
() async {
messenger.setMockMethodCallHandler(channel, (call) async {
throw PlatformException(code: 'avaudiosession_failed');
});
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await expectLater(controller.activate(), completes);
await expectLater(controller.deactivate(), completes);
});
test('activate swallows MissingPluginException when channel is absent',
() async {
final controller = IosAudioSessionController(
channel: channel,
isIos: true,
);
await expectLater(controller.activate(), completes);
await expectLater(controller.deactivate(), completes);
});
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/main.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
test('active poke chat suppresses same sender notification only', () {
final sender = BigInt.from(42);
expect(
isPokeSenderActiveChat(
chatOpen: true,
inlineChatTarget: rust.BridgeMessageTarget.poke(sender),
senderId: sender,
),
isTrue,
);
expect(
isPokeSenderActiveChat(
chatOpen: true,
inlineChatTarget: rust.BridgeMessageTarget.poke(BigInt.from(7)),
senderId: sender,
),
isFalse,
);
});
test('active private chat also suppresses same sender poke notification', () {
final sender = BigInt.from(42);
expect(
isPokeSenderActiveChat(
chatOpen: true,
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
senderId: sender,
),
isTrue,
);
expect(
isPokeSenderActiveChat(
chatOpen: false,
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
senderId: sender,
),
isFalse,
);
});
}
@@ -0,0 +1,81 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:chanora_flutter/services/poke_notification_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
late List<MethodCall> calls;
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return switch (call.method) {
'initialize' => true,
'requestNotificationsPermission' => true,
_ => null,
};
});
});
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test(
'show dispatches a silent poke notification with sender payload',
() async {
final service = PokeNotificationService();
await service.show(
senderName: 'Alice',
message: 'wake up',
senderId: BigInt.from(42),
strength: rust.BridgePokeStrength.strong,
);
final showCall = calls.singleWhere((call) => call.method == 'show');
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
expect(arguments['id'], 42);
expect(arguments['title'], 'Poke from Alice');
expect(arguments['body'], 'wake up');
expect(arguments['payload'], 'poke:42');
final specifics = Map<Object?, Object?>.from(
arguments['platformSpecifics'] as Map,
);
expect(specifics['silent'], true);
expect(specifics['playSound'], false);
expect(specifics['groupKey'], 'chanora.pokes');
},
);
test('show uses fallback body for empty poke messages', () async {
final service = PokeNotificationService();
await service.show(
senderName: 'Alice',
message: ' ',
senderId: BigInt.from(42),
strength: rust.BridgePokeStrength.strong,
);
final showCall = calls.singleWhere((call) => call.method == 'show');
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
expect(arguments['title'], 'Poke from Alice');
expect(arguments['body'], 'Alice pokes you');
expect(arguments['payload'], 'poke:42');
});
}
@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/services/poke_preferences_service.dart';
void main() {
late PokePreferencesService service;
setUp(() {
SharedPreferences.setMockInitialValues({});
service = PokePreferencesService();
});
test('loads defaults when preferences are unset', () async {
await service.load();
expect(service.pokesEnabled.value, isTrue);
expect(service.mutedSenders.value, isEmpty);
});
test('persists global enabled state', () async {
await service.load();
await service.setPokesEnabled(false);
final reloaded = PokePreferencesService();
await reloaded.load();
expect(reloaded.pokesEnabled.value, isFalse);
});
test('persists muted senders and removes them on unmute', () async {
await service.load();
final alice = BigInt.from(42);
final bob = BigInt.from(7);
await service.muteSender(alice);
await service.muteSender(bob);
await service.unmuteSender(alice);
final reloaded = PokePreferencesService();
await reloaded.load();
expect(reloaded.mutedSenders.value, {bob});
});
test('isMuted reflects in-memory changes synchronously', () async {
await service.load();
final sender = BigInt.from(99);
expect(service.isMuted(sender), isFalse);
await service.muteSender(sender);
expect(service.isMuted(sender), isTrue);
await service.unmuteSender(sender);
expect(service.isMuted(sender), isFalse);
});
}
@@ -0,0 +1,123 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/voice_join_ordering.dart';
void main() {
group('joinVoiceChannelWithIosAudioSession', () {
test('activates the iOS audio session before Rust voiceJoin', () async {
final calls = <String>[];
await joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: 'secret',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
expect(channelId, BigInt.from(42));
expect(password, 'secret');
calls.add('voiceJoin');
},
);
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
});
test('deactivates the iOS audio session when Rust voiceJoin fails',
() async {
final calls = <String>[];
await expectLater(
joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: '',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
calls.add('voiceJoin');
throw StateError('join rejected');
},
),
throwsStateError,
);
expect(calls, [
'activateIosAudioSession',
'voiceJoin',
'deactivateIosAudioSession',
]);
});
test(
'keeps the iOS audio session active when voiceJoin throws but the '
'error is recognised as already-in-channel (treated as success); '
'still rethrows so the caller runs its success-on-already-joined branch',
() async {
final calls = <String>[];
await expectLater(
joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: '',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
calls.add('voiceJoin');
throw _FakeAlreadyInChannel();
},
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
),
throwsA(isA<_FakeAlreadyInChannel>()),
);
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
},
);
test(
'deactivates the iOS audio session when isJoinSuccess returns false '
'for a non-success error',
() async {
final calls = <String>[];
await expectLater(
joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: '',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
calls.add('voiceJoin');
throw StateError('join rejected');
},
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
),
throwsStateError,
);
expect(calls, [
'activateIosAudioSession',
'voiceJoin',
'deactivateIosAudioSession',
]);
},
);
});
}
class _FakeAlreadyInChannel implements Exception {}
@@ -455,7 +455,7 @@ void main() {
channelName: '',
clientName: 'Alpha',
),
'Poke message...',
'Poke message optional...',
);
});
@@ -737,6 +737,179 @@ void main() {
refresh.dispose();
});
test('evaluates target-aware chat message send policy', () {
final clientTarget = rust.BridgeMessageTarget.client(BigInt.from(2));
final pokeTarget = rust.BridgeMessageTarget.poke(BigInt.from(2));
expect(canSendChatMessage(pokeTarget, null, ''), isTrue);
expect(canSendChatMessage(pokeTarget, null, ' '), isTrue);
expect(canSendChatMessage(pokeTarget, null, 'wake up'), isTrue);
expect(
canSendChatMessage(const rust.BridgeMessageTarget.server(), null, ''),
isFalse,
);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
'',
),
isFalse,
);
expect(canSendChatMessage(clientTarget, null, ''), isFalse);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
null,
'hello',
),
isFalse,
);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
'hello',
),
isTrue,
);
});
testWidgets('poke detail sends an empty poke when the composer is empty', (
tester,
) async {
String? sentMessage;
rust.BridgeMessageTarget? sentTarget;
final messages = <ChatEntry>[];
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
],
),
target: target,
clientName: 'Alpha',
currentChannelId: null,
channelName: '',
sendChatMessage: ({required message, required target}) async {
sentMessage = message;
sentTarget = target;
},
),
),
),
);
expect(find.byTooltip('Poke'), findsOneWidget);
expect(find.byTooltip('Send'), findsNothing);
await tester.tap(find.byTooltip('Poke'));
await tester.pump();
expect(sentMessage, '');
expect(sentTarget, target);
expect(messages, hasLength(1));
expect(messages.single.isPoke, isTrue);
expect(messages.single.message, '');
expect(find.textContaining('You poked "Alpha"'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
});
testWidgets('poke detail sends typed optional poke message', (tester) async {
String? sentMessage;
rust.BridgeMessageTarget? sentTarget;
final messages = <ChatEntry>[];
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
],
),
target: target,
clientName: 'Alpha',
currentChannelId: null,
channelName: '',
sendChatMessage: ({required message, required target}) async {
sentMessage = message;
sentTarget = target;
},
),
),
),
);
await tester.enterText(find.byType(TextField), 'wake up');
await tester.tap(find.byTooltip('Poke'));
await tester.pump();
expect(sentMessage, 'wake up');
expect(sentTarget, target);
expect(messages.single.message, 'wake up');
expect(
find.textContaining('You poked "Alpha" with message: wake up'),
findsOneWidget,
);
});
testWidgets('channel detail blocks empty sends with a joined channel', (
tester,
) async {
var sendCount = 0;
final messages = <ChatEntry>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: [channel(BigInt.from(10), 'Lobby')],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
],
),
target: const rust.BridgeMessageTarget.channel(),
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
sendChatMessage: ({required message, required target}) async {
sendCount++;
},
),
),
),
);
expect(find.byTooltip('Send'), findsOneWidget);
await tester.tap(find.byTooltip('Send'));
await tester.pump();
expect(sendCount, 0);
expect(messages, isEmpty);
});
test('blocks channel chat when no voice channel is joined', () {
expect(
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/poke_preferences_service.dart';
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
void main() {
testWidgets('toggles poke notifications and unmutes senders', (tester) async {
SharedPreferences.setMockInitialValues({});
final preferences = PokePreferencesService();
await preferences.load();
await preferences.muteSender(BigInt.from(42));
addTearDown(preferences.dispose);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: PokeNotificationSettingsDialog(preferences: preferences),
),
);
expect(find.text('Poke notifications'), findsOneWidget);
expect(find.text('Client ID 42'), findsOneWidget);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(preferences.pokesEnabled.value, isFalse);
await tester.tap(find.text('Unmute'));
await tester.pumpAndSettle();
expect(preferences.isMuted(BigInt.from(42)), isFalse);
expect(find.text('No muted poke senders.'), findsOneWidget);
});
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/widgets/voice_compact.dart';
void main() {
testWidgets('touch PTT releases when disposed while held', (tester) async {
final heldChanges = <bool>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: VoicePttButton(
active: false,
onHeldChanged: heldChanges.add,
),
),
),
);
final center = tester.getCenter(find.byType(VoicePttButton));
final gesture = await tester.startGesture(center);
await tester.pump();
expect(heldChanges, [true]);
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
expect(heldChanges, [true, false]);
await gesture.cancel();
expect(heldChanges, [true, false]);
});
}
@@ -13,6 +13,24 @@ void main() {
]);
});
test('gated transmit mode segments drop VAD when unsupported', () {
expect(
transmitModeSegmentsFor(voiceActivityAvailable: false).map((s) => s.value),
[rust.BridgeTransmitMode.ptt, rust.BridgeTransmitMode.continuous],
);
});
test('gated transmit mode segments include VAD when supported', () {
expect(
transmitModeSegmentsFor(voiceActivityAvailable: true).map((s) => s.value),
[
rust.BridgeTransmitMode.ptt,
rust.BridgeTransmitMode.continuous,
rust.BridgeTransmitMode.voiceActivity,
],
);
});
test('shared Android processing segments expose hardware and WebRTC', () {
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
});
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
jni
)
+2 -1
View File
@@ -10,6 +10,7 @@ repository.workspace = true
publish.workspace = true
[dependencies]
chanora_cache = { path = "../../crates/chanora_cache" }
chanora_protocol = { path = "../../crates/chanora_protocol" }
chanora_state = { path = "../../crates/chanora_state" }
chanora_audio = { path = "../../crates/chanora_audio" }
@@ -18,7 +19,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
thiserror.workspace = true
tracing.workspace = true
tokio = { version = "1", features = ["sync", "rt", "macros"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
[dev-dependencies]
# Used by integration tests to inspect the bookmark DB row layout
+287
View File
@@ -0,0 +1,287 @@
use chanora_audio::{AudioRoute, PttBackendDescriptor};
use chanora_protocol::{MessageTarget, PokeStrength};
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttDescriptorSnapshot {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
fn from(desc: PttBackendDescriptor) -> Self {
Self {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
}
}
}
/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl PersistedPttBinding {
pub(crate) fn empty() -> Self {
Self {
input_class: String::new(),
key_label: String::new(),
}
}
}
/// High-level lifecycle event surfaced to subscribers.
///
/// This is the minimal set needed for A.6 (reconnect banner). The
/// full event catalogue lands in A.4.
#[derive(Debug, Clone)]
pub enum SessionEvent {
/// Initial connect succeeded, or reconnect attempt succeeded.
Connected {
/// Server name reported in the snapshot.
server_name: String,
},
/// Connection lost; the supervisor will retry.
Lost {
/// Reason classification from the protocol layer.
reason: String,
},
/// Supervisor is sleeping before its next reconnect attempt.
Reconnecting {
/// 1-based attempt counter for the current outage.
attempt: u32,
/// Seconds the supervisor will sleep before this attempt.
delay_secs: u32,
},
/// Supervisor gave up after `attempt` failed retries (or the
/// user explicitly disconnected mid-outage).
Disconnected {
/// Reason classification from the protocol layer.
reason: String,
},
/// Audio engine started (e.g. after a successful reconnect with
/// reattachment).
AudioStarted,
/// Audio engine stopped (e.g. before a reconnect cycle, or by
/// explicit user action).
AudioStopped,
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
/// DEC-023..028). Published when the audio engine starts or
/// when the active backend transitions (for example macOS
/// permission state change). Carries only the privacy-safe
/// descriptor — capability level, backend identifier, bound
/// input class — per SRS-202 / DEC-027.
PttCapability {
/// Stable level name from `PttCapabilityLevel::as_str()`.
level: String,
/// Stable backend identifier (e.g. `"focused"`).
backend_id: String,
/// Coarse bound input class (e.g. `"keyboard"`); empty when
/// no binding is active.
bound_input_class: String,
},
/// Voice subsystem state snapshot (SDD-094). Emitted on
/// `voice_join` / `voice_leave`, transmit-mode changes,
/// hard-mute toggles, and release-tail edits.
VoiceState {
/// True when the user has joined a voice channel via
/// `voice_join` and the audio engine is running.
in_channel: bool,
/// Active transmit mode encoded as
/// [`chanora_audio::TransmitMode::as_u8`].
transmit_mode: u8,
/// True when the hard-mute clamp is engaged.
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
/// Last confirmed authoritative channel id from the
/// `channel_join` reducer projection.
current_channel_id: Option<u64>,
/// Non-authoritative pending target channel id from the
/// reducer projection.
pending_target_channel_id: Option<u64>,
/// Whether the reducer currently allows a new join intent.
can_join: bool,
/// Whether the reducer currently allows leave intent.
can_leave: bool,
/// Join projection synchronization state.
join_sync_state: VoiceJoinSyncState,
/// Last stable sanitized join error code, if any.
join_error_code: Option<VoiceJoinErrorCode>,
},
/// iOS audio-session interruption state (SDD-101). Emitted when
/// interruption begins and when it ends (with the platform hint
/// indicating whether audio should resume).
InterruptionState {
/// True when interruption began, false when interruption ended.
began: bool,
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
/// A text message was received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
/// Poke notification strength, present only for poke messages.
poke_strength: Option<PokeStrength>,
},
/// Human-readable TeamSpeak-style server activity.
ServerActivity {
/// Activity line text.
message: String,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio output route.
route: AudioRoute,
},
/// A client moved to a different channel.
ClientMoved {
/// Unique client identifier.
client_id: u64,
/// Destination channel.
new_channel_id: u64,
},
/// A new client connected.
ClientJoined {
/// Unique client identifier.
client_id: u64,
/// Channel the client joined.
channel_id: u64,
/// Display nickname.
name: String,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A client disconnected.
ClientLeft {
/// Unique client identifier.
client_id: u64,
/// Display nickname at time of disconnect.
name: String,
},
/// Client properties changed.
ClientUpdated {
/// Unique client identifier.
client_id: u64,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A new channel appeared.
ChannelAdded {
/// Unique channel identifier.
id: u64,
/// Parent channel ID.
parent: u64,
/// Channel name.
name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
/// A channel was deleted.
ChannelRemoved {
/// Channel identifier.
id: u64,
},
/// Channel properties changed.
ChannelUpdated {
/// Unique channel identifier.
id: u64,
/// Channel name.
name: String,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
}
/// Bridge-safe mirror of channel-join projection sync state.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is synchronizing against an initial snapshot.
SynchronizingInitialSnapshot,
/// Reducer is synchronizing after reconnect.
SynchronizingReconnect,
}
/// Bridge-safe mirror of stable channel-join error codes.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
/// A different target was requested while one is already pending.
JoinAlreadyPendingDifferentTarget,
/// Join denied by server policy/permission.
JoinDenied,
/// Join failed due to protocol-level error.
JoinProtocolFailure,
/// Join failed due to transport/network error.
JoinNetworkFailure,
/// Join timed out awaiting confirmation.
JoinTimeout,
/// Pending join was superseded by user leave.
JoinSupersededByLeave,
/// Stale join outcome was ignored.
JoinStaleOutcomeIgnored,
/// Authoritative membership reconciled to different channel.
JoinReconciledDifferentChannel,
/// Join command was rejected before send acceptance.
JoinCommandRejectedBeforeSend,
/// Join intent rejected while reducer synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Coarse OS-reported network state. Populated by the Flutter side
/// via `connectivity_plus`; on platforms where no signal is wired
/// we stay at `Unknown` forever and the supervisor falls back to
/// pure watchdog/backoff behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
/// No signal seen yet — treat as ambiguous; don't change behaviour.
Unknown,
/// OS reports at least one network with internet capability.
Online,
/// OS reports no networks available.
Offline,
}
+344
View File
@@ -0,0 +1,344 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR, PREFIX_ICON};
use chanora_protocol::{ProtocolClient, ProtocolError};
use tokio::sync::{Mutex, Semaphore, oneshot};
use tracing::warn;
const MAX_CONCURRENT_DOWNLOADS: usize = 2;
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
type InFlightWaiters = Vec<oneshot::Sender<Result<Option<Vec<u8>>, FileTransferError>>>;
/// Errors raised while resolving protocol-owned file assets.
#[derive(Debug, thiserror::Error)]
pub enum FileTransferError {
/// No live protocol client is available for a download.
#[error("not connected")]
NotConnected,
/// The protocol layer failed while downloading the asset.
#[error("protocol error: {0}")]
Protocol(#[from] ProtocolError),
/// The blob cache failed while reading or writing the asset.
#[error("cache error: {0}")]
Cache(#[from] BlobCacheError),
}
impl Clone for FileTransferError {
fn clone(&self) -> Self {
match self {
Self::NotConnected => Self::NotConnected,
Self::Protocol(error) => Self::Protocol(clone_protocol_error(error)),
Self::Cache(error) => Self::Cache(clone_blob_cache_error(error)),
}
}
}
pub struct FileTransferService {
cache: BlobCache,
protocol: Arc<Mutex<Option<ProtocolClient>>>,
semaphore: Arc<Semaphore>,
in_flight: Arc<Mutex<HashMap<String, InFlightWaiters>>>,
negative_cache: Arc<Mutex<HashMap<String, Instant>>>,
}
impl FileTransferService {
pub fn new(cache: BlobCache, protocol: Arc<Mutex<Option<ProtocolClient>>>) -> Self {
Self {
cache,
protocol,
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)),
in_flight: Arc::new(Mutex::new(HashMap::new())),
negative_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn set_protocol(&self, client: Option<ProtocolClient>) {
*self.protocol.lock().await = client;
}
pub async fn get_avatar(
&self,
avatar_hash: &str,
client_uid: &str,
) -> Result<Option<Vec<u8>>, FileTransferError> {
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
return Ok(Some(bytes));
}
if self.is_negative_cache_hit(avatar_hash).await {
return Ok(None);
}
let rx = {
let mut in_flight = self.in_flight.lock().await;
if let Some(waiters) = in_flight.get_mut(avatar_hash) {
let (tx, rx) = oneshot::channel();
waiters.push(tx);
Some(rx)
} else {
in_flight.insert(avatar_hash.to_string(), Vec::new());
None
}
};
if let Some(rx) = rx {
return rx.await.unwrap_or_else(|_| {
Err(FileTransferError::Protocol(ProtocolError::Lost(
"coalesced avatar download waiter dropped".to_string(),
)))
});
}
let _permit = self
.semaphore
.acquire()
.await
.expect("file transfer semaphore should stay open");
let result = self.do_download_avatar(avatar_hash, client_uid).await;
self.finish_in_flight(avatar_hash, &result).await;
result
}
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
let icon_key = icon_id.to_string();
let negative_key = format!("ic_{icon_id}");
let in_flight_key = format!("icon_{icon_id}");
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
return Ok(Some(bytes));
}
if self.is_negative_cache_hit(&negative_key).await {
return Ok(None);
}
let rx = {
let mut in_flight = self.in_flight.lock().await;
if let Some(waiters) = in_flight.get_mut(&in_flight_key) {
let (tx, rx) = oneshot::channel();
waiters.push(tx);
Some(rx)
} else {
in_flight.insert(in_flight_key.clone(), Vec::new());
None
}
};
if let Some(rx) = rx {
return rx.await.unwrap_or_else(|_| {
Err(FileTransferError::Protocol(ProtocolError::Lost(
"coalesced icon download waiter dropped".to_string(),
)))
});
}
let _permit = self
.semaphore
.acquire()
.await
.expect("file transfer semaphore should stay open");
let result = self.do_download_icon(icon_id).await;
self.finish_in_flight(&in_flight_key, &result).await;
result
}
pub async fn clear_cache(&self) -> Result<(), FileTransferError> {
self.cache.clear().await?;
self.negative_cache.lock().await.clear();
Ok(())
}
pub async fn cache_size(&self) -> Result<u64, FileTransferError> {
Ok(self.cache.total_size().await?)
}
async fn do_download_avatar(
&self,
avatar_hash: &str,
client_uid: &str,
) -> Result<Option<Vec<u8>>, FileTransferError> {
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
return Ok(Some(bytes));
}
if self.is_negative_cache_hit(avatar_hash).await {
return Ok(None);
}
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
match client.download_avatar(client_uid).await {
Ok(bytes) => {
self.cache.put(PREFIX_AVATAR, avatar_hash, &bytes).await?;
self.negative_cache.lock().await.remove(avatar_hash);
Ok(Some(bytes))
}
Err(ProtocolError::ServerRejected { .. }) => {
self.negative_cache
.lock()
.await
.insert(avatar_hash.to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
Ok(None)
}
Err(error) => Err(FileTransferError::Protocol(error)),
}
}
async fn do_download_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
let icon_key = icon_id.to_string();
let negative_key = format!("ic_{icon_id}");
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
return Ok(Some(bytes));
}
if self.is_negative_cache_hit(&negative_key).await {
return Ok(None);
}
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
match client.download_icon(icon_id).await {
Ok(bytes) => {
self.cache.put(PREFIX_ICON, &icon_key, &bytes).await?;
self.negative_cache.lock().await.remove(&negative_key);
Ok(Some(bytes))
}
Err(ProtocolError::ServerRejected { .. }) => {
self.negative_cache
.lock()
.await
.insert(negative_key, Instant::now() + NEGATIVE_CACHE_TTL);
Ok(None)
}
Err(error) => Err(FileTransferError::Protocol(error)),
}
}
async fn finish_in_flight(
&self,
avatar_hash: &str,
result: &Result<Option<Vec<u8>>, FileTransferError>,
) {
let waiters = self.in_flight.lock().await.remove(avatar_hash).unwrap_or_default();
for waiter in waiters {
if waiter.send(result.clone()).is_err() {
warn!(target: "chanora_core", avatar_hash, "avatar download waiter dropped");
}
}
}
async fn is_negative_cache_hit(&self, avatar_hash: &str) -> bool {
let now = Instant::now();
let mut negative_cache = self.negative_cache.lock().await;
match negative_cache.get(avatar_hash).copied() {
Some(expires_at) if expires_at > now => true,
Some(_) => {
negative_cache.remove(avatar_hash);
false
}
None => false,
}
}
}
fn clone_protocol_error(error: &ProtocolError) -> ProtocolError {
match error {
ProtocolError::Invalid(message) => ProtocolError::Invalid(message.clone()),
ProtocolError::DnsFailed { host, reason } => ProtocolError::DnsFailed {
host: host.clone(),
reason: reason.clone(),
},
ProtocolError::Connect(message) => ProtocolError::Connect(message.clone()),
ProtocolError::DisconnectedEarly(message) => {
ProtocolError::DisconnectedEarly(message.clone())
}
ProtocolError::Lost(message) => ProtocolError::Lost(message.clone()),
ProtocolError::Identity(message) => ProtocolError::Identity(message.clone()),
ProtocolError::Timeout => ProtocolError::Timeout,
ProtocolError::ServerRejected { code, message } => ProtocolError::ServerRejected {
code: *code,
message: message.clone(),
},
ProtocolError::Backend(message) => ProtocolError::Backend(message.clone()),
ProtocolError::FileTransfer(message) => ProtocolError::FileTransfer(message.clone()),
}
}
fn clone_blob_cache_error(error: &BlobCacheError) -> BlobCacheError {
match error {
BlobCacheError::Io(message) => BlobCacheError::Io(message.clone()),
BlobCacheError::InvalidKey(message) => BlobCacheError::InvalidKey(message.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_cache_dir(name: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
path.push(format!("chanora-core-file-transfer-{name}-{nanos}"));
path
}
#[tokio::test]
async fn returns_cached_avatar_without_connection() {
let cache_dir = test_cache_dir("cache-hit");
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
cache.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar")
.await
.unwrap();
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
let avatar = service
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
.await
.unwrap();
assert_eq!(avatar, Some(b"avatar".to_vec()));
let _ = std::fs::remove_dir_all(cache_dir);
}
#[tokio::test]
async fn negative_cache_short_circuits_not_connected() {
let cache_dir = test_cache_dir("negative-cache");
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
service
.negative_cache
.lock()
.await
.insert("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
let avatar = service
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
.await
.unwrap();
assert_eq!(avatar, None);
let _ = std::fs::remove_dir_all(cache_dir);
}
#[tokio::test]
async fn returns_cached_icon_without_connection() {
let cache_dir = test_cache_dir("icon-cache-hit");
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
let icon = service.get_icon(12345).await.unwrap();
assert_eq!(icon, Some(b"icon".to_vec()));
let _ = std::fs::remove_dir_all(cache_dir);
}
}
+340 -382
View File
@@ -52,6 +52,9 @@ use chanora_state::channel_join::{
ConnectionEpoch, JoinFailureKind,
};
mod events;
mod file_transfer;
mod network_diagnostics;
pub mod ptt;
pub use chanora_audio::{
@@ -66,49 +69,15 @@ pub use chanora_diagnostics::{
};
pub use chanora_protocol::{
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttDescriptorSnapshot {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
fn from(desc: PttBackendDescriptor) -> Self {
Self {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
}
}
}
/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl PersistedPttBinding {
fn empty() -> Self {
Self {
input_class: String::new(),
key_label: String::new(),
}
}
}
pub use events::{
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
VoiceJoinSyncState,
};
pub use file_transfer::FileTransferError;
use network_diagnostics::NetworkDiagnostics;
/// Errors that can arise during top-level orchestration.
#[derive(Debug, Error)]
@@ -125,6 +94,12 @@ pub enum CoreError {
/// Storage error.
#[error("storage: {0}")]
Storage(#[from] chanora_storage::StorageError),
/// Blob-cache failure.
#[error("cache: {0}")]
Cache(#[from] chanora_cache::BlobCacheError),
/// File-transfer failure.
#[error("file transfer: {0}")]
FileTransfer(#[from] FileTransferError),
/// Diagnostics error.
#[error("diagnostics: {0}")]
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
@@ -147,291 +122,11 @@ pub enum CoreError {
Ptt(#[from] ptt::PttControllerError),
}
/// High-level lifecycle event surfaced to subscribers.
///
/// This is the minimal set needed for A.6 (reconnect banner). The
/// full event catalogue lands in A.4.
#[derive(Debug, Clone)]
pub enum SessionEvent {
/// Initial connect succeeded, or reconnect attempt succeeded.
Connected {
/// Server name reported in the snapshot.
server_name: String,
},
/// Connection lost; the supervisor will retry.
Lost {
/// Reason classification from the protocol layer.
reason: String,
},
/// Supervisor is sleeping before its next reconnect attempt.
Reconnecting {
/// 1-based attempt counter for the current outage.
attempt: u32,
/// Seconds the supervisor will sleep before this attempt.
delay_secs: u32,
},
/// Supervisor gave up after `attempt` failed retries (or the
/// user explicitly disconnected mid-outage).
Disconnected {
/// Reason classification from the protocol layer.
reason: String,
},
/// Audio engine started (e.g. after a successful reconnect with
/// reattachment).
AudioStarted,
/// Audio engine stopped (e.g. before a reconnect cycle, or by
/// explicit user action).
AudioStopped,
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
/// DEC-023..028). Published when the audio engine starts or
/// when the active backend transitions (for example macOS
/// permission state change). Carries only the privacy-safe
/// descriptor — capability level, backend identifier, bound
/// input class — per SRS-202 / DEC-027.
PttCapability {
/// Stable level name from `PttCapabilityLevel::as_str()`.
level: String,
/// Stable backend identifier (e.g. `"focused"`).
backend_id: String,
/// Coarse bound input class (e.g. `"keyboard"`); empty when
/// no binding is active.
bound_input_class: String,
},
/// Voice subsystem state snapshot (SDD-094). Emitted on
/// `voice_join` / `voice_leave`, transmit-mode changes,
/// hard-mute toggles, and release-tail edits.
VoiceState {
/// True when the user has joined a voice channel via
/// `voice_join` and the audio engine is running.
in_channel: bool,
/// Active transmit mode encoded as
/// [`chanora_audio::TransmitMode::as_u8`].
transmit_mode: u8,
/// True when the hard-mute clamp is engaged.
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
/// Last confirmed authoritative channel id from the
/// `channel_join` reducer projection.
current_channel_id: Option<u64>,
/// Non-authoritative pending target channel id from the
/// reducer projection.
pending_target_channel_id: Option<u64>,
/// Whether the reducer currently allows a new join intent.
can_join: bool,
/// Whether the reducer currently allows leave intent.
can_leave: bool,
/// Join projection synchronization state.
join_sync_state: VoiceJoinSyncState,
/// Last stable sanitized join error code, if any.
join_error_code: Option<VoiceJoinErrorCode>,
},
/// iOS audio-session interruption state (SDD-101). Emitted when
/// interruption begins and when it ends (with the platform hint
/// indicating whether audio should resume).
InterruptionState {
/// True when interruption began, false when interruption ended.
began: bool,
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
/// A text message was received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
},
/// Human-readable TeamSpeak-style server activity.
ServerActivity {
/// Activity line text.
message: String,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio output route.
route: AudioRoute,
},
/// A client moved to a different channel.
ClientMoved {
/// Unique client identifier.
client_id: u64,
/// Destination channel.
new_channel_id: u64,
},
/// A new client connected.
ClientJoined {
/// Unique client identifier.
client_id: u64,
/// Channel the client joined.
channel_id: u64,
/// Display nickname.
name: String,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A client disconnected.
ClientLeft {
/// Unique client identifier.
client_id: u64,
/// Display nickname at time of disconnect.
name: String,
},
/// Client properties changed.
ClientUpdated {
/// Unique client identifier.
client_id: u64,
/// Microphone muted state.
input_muted: bool,
/// Speaker muted state.
output_muted: bool,
/// Whether this is a server query (bot) client.
is_server_query: bool,
/// Client's talk power value.
talk_power: i32,
/// Whether the server granted temporary talk power.
talk_power_granted: bool,
},
/// A new channel appeared.
ChannelAdded {
/// Unique channel identifier.
id: u64,
/// Parent channel ID.
parent: u64,
/// Channel name.
name: String,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
order: i64,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
/// A channel was deleted.
ChannelRemoved {
/// Channel identifier.
id: u64,
},
/// Channel properties changed.
ChannelUpdated {
/// Unique channel identifier.
id: u64,
/// Channel name.
name: String,
/// Whether the channel requires a password.
has_password: bool,
/// Talk power required to speak, or `None` when unrestricted.
needed_talk_power: Option<i32>,
},
}
/// Bridge-safe mirror of channel-join projection sync state.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is synchronizing against an initial snapshot.
SynchronizingInitialSnapshot,
/// Reducer is synchronizing after reconnect.
SynchronizingReconnect,
}
/// Bridge-safe mirror of stable channel-join error codes.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
/// A different target was requested while one is already pending.
JoinAlreadyPendingDifferentTarget,
/// Join denied by server policy/permission.
JoinDenied,
/// Join failed due to protocol-level error.
JoinProtocolFailure,
/// Join failed due to transport/network error.
JoinNetworkFailure,
/// Join timed out awaiting confirmation.
JoinTimeout,
/// Pending join was superseded by user leave.
JoinSupersededByLeave,
/// Stale join outcome was ignored.
JoinStaleOutcomeIgnored,
/// Authoritative membership reconciled to different channel.
JoinReconciledDifferentChannel,
/// Join command was rejected before send acceptance.
JoinCommandRejectedBeforeSend,
/// Join intent rejected while reducer synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Coarse OS-reported network state. Populated by the Flutter side
/// via `connectivity_plus`; on platforms where no signal is wired
/// we stay at `Unknown` forever and the supervisor falls back to
/// pure watchdog/backoff behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
/// No signal seen yet — treat as ambiguous; don't change behaviour.
Unknown,
/// OS reports at least one network with internet capability.
Online,
/// OS reports no networks available.
Offline,
}
/// Channel capacity for the broadcast events. Generous because
/// reconnect cycles emit several events per attempt; if subscribers
/// fall behind we'd rather skip than block the supervisor.
const EVENT_CHANNEL_CAPACITY: usize = 64;
/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
struct NetworkDiagnostics {
/// Total count of connects (including the initial one).
connect_count: u64,
/// Count of disconnects (graceful + loss).
disconnect_count: u64,
/// Recent loss reasons (last 8, ring buffer).
loss_reasons: Vec<String>,
}
impl NetworkDiagnostics {
fn record_connect(&mut self) {
self.connect_count = self.connect_count.saturating_add(1);
}
fn record_loss(&mut self, reason: &str) {
self.disconnect_count = self.disconnect_count.saturating_add(1);
if self.loss_reasons.len() >= 8 {
self.loss_reasons.remove(0);
}
self.loss_reasons.push(reason.to_string());
}
fn summary(&self) -> String {
let mut s = format!(
"connects: {}\ndisconnects: {}\n",
self.connect_count, self.disconnect_count
);
if !self.loss_reasons.is_empty() {
s.push_str(&format!(
"loss_reasons: [{}]\n",
self.loss_reasons.join(", ")
));
}
s
}
}
struct SupervisorInner {
/// Optional cached AudioEngineConfig — set when start_audio is
/// first called, used to re-create the engine after a reconnect.
@@ -443,7 +138,6 @@ struct SupervisorInner {
}
struct ConnectedState {
protocol: chanora_protocol::ProtocolClient,
audio: Option<chanora_audio::AudioEngine>,
/// Active PTT controller (SDD-088). Owns the platform input
/// backend, the active binding, and the capability watch
@@ -477,12 +171,20 @@ struct ConnectedState {
local_output_muted: bool,
}
async fn take_disconnect_state<T>(inner: &Arc<Mutex<Option<T>>>) -> Option<T> {
inner.lock().await.take()
}
fn normalize_channel_password(password: Option<String>) -> Option<String> {
password
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
}
fn should_dispatch_text_message(message: &str, target: &MessageTarget) -> bool {
!message.trim().is_empty() || matches!(target, MessageTarget::Poke(_))
}
/// The top-level Chanora session. Owns at most one active server
/// connection (DEC-006).
#[derive(Clone)]
@@ -502,6 +204,8 @@ pub struct ChanoraSession {
/// extension). Lives alongside the identity file. Wired by
/// [`Self::init_storage`].
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
file_transfer: Arc<Mutex<Option<Arc<file_transfer::FileTransferService>>>>,
/// Invisible server-address prefetch cache. Warmed by Flutter typing
/// but validated by Rust before Connect can reuse it.
server_prefetch: ServerPrefetcher,
@@ -557,6 +261,8 @@ impl ChanoraSession {
network_tx,
identity_store: Arc::new(Mutex::new(None)),
bookmark_store: Arc::new(Mutex::new(None)),
protocol: Arc::new(Mutex::new(None)),
file_transfer: Arc::new(Mutex::new(None)),
server_prefetch: ServerPrefetcher::new(),
voice_selector: selector,
release_tail,
@@ -578,6 +284,17 @@ impl ChanoraSession {
ConnectionEpoch(epoch)
}
async fn store_protocol(&self, client: Option<chanora_protocol::ProtocolClient>) {
// Always update the shared Arc. The FileTransferService holds
// the same Arc, so it sees the new client automatically — no
// separate set_protocol call needed.
*self.protocol.lock().await = client;
}
async fn take_protocol(&self) -> Option<chanora_protocol::ProtocolClient> {
self.protocol.lock().await.take()
}
/// Wire a directory-backed identity store. Called by the bridge
/// during `bridge_init` once Flutter has resolved the platform
/// app-private storage directory. Subsequent [`Self::connect`]
@@ -642,6 +359,65 @@ impl ChanoraSession {
Ok(())
}
/// Configure the blob cache root.
pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> {
let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?;
cache.evict().await?;
let service = Arc::new(file_transfer::FileTransferService::new(
cache,
self.protocol.clone(),
));
let mut guard = self.file_transfer.lock().await;
*guard = Some(service);
Ok(())
}
/// Resolve avatar bytes.
pub async fn get_avatar(
&self,
avatar_hash: &str,
client_uid: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
let service = { self.file_transfer.lock().await.clone() };
if let Some(service) = service {
return Ok(service.get_avatar(avatar_hash, client_uid).await?);
}
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
Ok(Some(client.download_avatar(client_uid).await?))
}
/// Resolve icon bytes.
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, CoreError> {
let service = { self.file_transfer.lock().await.clone() };
if let Some(service) = service {
return Ok(service.get_icon(icon_id).await?);
}
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
Ok(Some(client.download_icon(icon_id).await?))
}
/// Purge cached protocol-owned assets.
pub async fn clear_cache(&self) -> Result<(), CoreError> {
let service = { self.file_transfer.lock().await.clone() };
if let Some(service) = service {
service.clear_cache().await?;
}
Ok(())
}
/// Report the configured blob-cache size.
pub async fn cache_size(&self) -> Result<u64, CoreError> {
let service = { self.file_transfer.lock().await.clone() };
match service {
Some(service) => Ok(service.cache_size().await?),
None => Ok(0),
}
}
/// List persisted bookmarks. Returns an empty list if the store
/// has not been wired or has no entries.
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
@@ -819,6 +595,7 @@ impl ChanoraSession {
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
state_arc: self.inner.clone(),
protocol: self.protocol.clone(),
events_tx: self.events_tx.clone(),
initial_cfg: cfg.clone(),
initial_lost_rx: lost_rx,
@@ -873,9 +650,9 @@ impl ChanoraSession {
}
spawn_event_forwarders(&client, &self.events_tx);
self.store_protocol(Some(client)).await;
*guard = Some(ConnectedState {
protocol: client,
audio: None,
ptt_controller: None,
cancel_tx: Some(cancel_tx),
@@ -936,9 +713,13 @@ impl ChanoraSession {
/// Return a fresh snapshot of the current server state.
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
let snap = {
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
client.snapshot().await?
};
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
let snap = state.protocol.snapshot().await?;
let current_channel = self
.find_own_in(&snap)
.await
@@ -966,9 +747,9 @@ impl ChanoraSession {
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
Ok(state.protocol.client_profile(client_id).await?)
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
Ok(client.client_profile(client_id).await?)
}
/// True if a connection is currently active.
@@ -982,12 +763,12 @@ impl ChanoraSession {
message: String,
target: MessageTarget,
) -> Result<(), CoreError> {
if message.trim().is_empty() {
if !should_dispatch_text_message(&message, &target) {
return Ok(());
}
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
state.protocol.send_text_message(message, target).await?;
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
client.send_text_message(message, target).await?;
Ok(())
}
@@ -1046,11 +827,16 @@ impl ChanoraSession {
// session permanently unable to restart audio without a
// reconnect (the user saw "voice_in already taken" on the
// second channel switch).
let voice_out = state.protocol.voice_out();
let voice_in = state
.protocol
let (voice_out, voice_in) = {
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
(
client.voice_out(),
client
.take_voice_in()
.ok_or(CoreError::Invariant("voice_in already taken"))?;
.ok_or(CoreError::Invariant("voice_in already taken"))?,
)
};
let gate = AudioTransmitGate::new(cfg.ptt_initial);
cfg.voice_activity_selector = Some(self.voice_selector.clone());
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
@@ -1281,10 +1067,11 @@ impl ChanoraSession {
let password_to_send = requested_password
.clone()
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
state
.protocol
.move_to_channel(channel_id, password_to_send)
.await?;
{
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
client.move_to_channel(channel_id, password_to_send).await?;
}
if let Some(pw) = requested_password {
state.channel_passwords.insert(channel_id, pw);
}
@@ -1309,7 +1096,11 @@ impl ChanoraSession {
if let Some(muted) = output {
state.local_output_muted = muted;
}
state.protocol.set_muted(input, output).await?;
{
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
client.set_muted(input, output).await?;
}
if let Some(muted) = output {
if let Some(audio) = state.audio.as_ref() {
audio.set_output_muted(muted);
@@ -1421,11 +1212,19 @@ impl ChanoraSession {
/// Configure the preferred Silero ONNX VAD model path on platforms
/// that ship the ONNX detector.
///
/// This does not require an active connection. Running non-iOS
/// audio backends can observe the model-path epoch and reload on
/// the next capture frame when Silero is selected.
/// Set the Silero VAD model path on supported platforms.
///
/// This does not require an active connection. On desktop, the audio
/// engine immediately reloads the Silero ONNX worker if one is active
/// or if the model file is now available at the new path.
pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
chanora_audio::vad::set_silero_model_path(&path)?;
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
if let Some(audio) = state.audio.as_ref() {
audio.reload_audio_processing_config()?;
}
}
Ok(())
}
@@ -1624,11 +1423,12 @@ impl ChanoraSession {
let password_to_send = requested_password
.clone()
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
if let Err(e) = state
.protocol
.queue_move_to_channel(channel_id, password_to_send)
.await
{
let move_result = {
let protocol = self.protocol.lock().await;
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
client.queue_move_to_channel(channel_id, password_to_send).await
};
if let Err(e) = move_result {
// TS3 error 0x0302 = `channel_already_in`: we're already
// in the target channel, so this is a no-op success.
// Rolling `in_channel` back to false would break PTT
@@ -1878,8 +1678,7 @@ impl ChanoraSession {
/// Disconnect from the server. No-op if not connected.
pub async fn disconnect(&self) -> Result<(), CoreError> {
let mut guard = self.inner.lock().await;
if let Some(mut state) = guard.take() {
if let Some(mut state) = take_disconnect_state(&self.inner).await {
// Signal the supervisor to exit (cancels any backoff sleep).
if let Some(tx) = state.cancel_tx.take() {
let _ = tx.send(());
@@ -1891,11 +1690,13 @@ impl ChanoraSession {
audio.stop();
let _ = self.events_tx.send(SessionEvent::AudioStopped);
}
state.protocol.disconnect().await;
if let Some(protocol) = self.take_protocol().await {
protocol.disconnect().await;
}
// Wait for the supervisor to wind down so we don't race
// a redial against the explicit disconnect.
if let Some(handle) = state.supervisor.take() {
let _ = handle.await;
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
}
let _ = self.events_tx.send(SessionEvent::Disconnected {
reason: "user requested".to_string(),
@@ -1934,9 +1735,26 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// Number of consecutive watchdog failures before the supervisor
/// declares the connection lost.
const WATCHDOG_MAX_MISSES: u32 = 3;
const SUPERVISOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration: Duration) {
if tokio::time::timeout(timeout_duration, &mut handle)
.await
.is_err()
{
warn!(
target: "chanora_core",
timeout_ms = timeout_duration.as_millis() as u64,
"supervisor did not stop before shutdown timeout"
);
handle.abort();
let _ = handle.await;
}
}
struct SupervisorContext {
state_arc: Arc<Mutex<Option<ConnectedState>>>,
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
events_tx: broadcast::Sender<SessionEvent>,
initial_cfg: ConnectConfig,
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
@@ -1968,6 +1786,7 @@ fn spawn_event_forwarders(
sender_name: msg.sender_name,
message: msg.message,
target: msg.target,
poke_strength: msg.poke_strength,
});
}
});
@@ -1989,27 +1808,77 @@ fn spawn_event_forwarders(
let mut rx = delta_rx;
while let Some(delta) = rx.recv().await {
let event = match delta {
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
SessionEvent::ClientMoved { client_id, new_channel_id }
}
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
}
ProtocolDelta::ClientMoved {
client_id,
new_channel_id,
} => SessionEvent::ClientMoved {
client_id,
new_channel_id,
},
ProtocolDelta::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => SessionEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
},
ProtocolDelta::ClientLeft { client_id, name } => {
SessionEvent::ClientLeft { client_id, name }
}
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
}
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
}
ProtocolDelta::ChannelRemoved { id } => {
SessionEvent::ChannelRemoved { id }
}
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
}
ProtocolDelta::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => SessionEvent::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
},
ProtocolDelta::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} => SessionEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
},
ProtocolDelta::ChannelRemoved { id } => SessionEvent::ChannelRemoved { id },
ProtocolDelta::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} => SessionEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
},
};
let _ = ev_tx.send(event);
}
@@ -2020,6 +1889,7 @@ fn spawn_event_forwarders(
async fn supervisor_loop(ctx: SupervisorContext) {
let SupervisorContext {
state_arc,
protocol,
events_tx,
initial_cfg,
initial_lost_rx,
@@ -2289,20 +2159,21 @@ async fn supervisor_loop(ctx: SupervisorContext) {
// Reattach into the session state.
let restart_audio = {
let guard = state_arc.lock().await;
if guard.is_none() {
// Session was disposed mid-reconnect.
return;
}
drop(guard);
let old = protocol.lock().await.replace(new_client);
drop(old);
let mut guard = state_arc.lock().await;
let state = match guard.as_mut() {
Some(s) => s,
None => {
// Session was disposed mid-reconnect.
return;
}
};
// Replace the dead protocol client with the new one.
// The old client's background task either already
// exited (loss notifier fired) or will exit when
// its request channel drops (watchdog path).
let old = std::mem::replace(&mut state.protocol, new_client);
drop(old);
let _ = channel_join::reduce(
&mut state.join_state,
@@ -2332,9 +2203,9 @@ async fn supervisor_loop(ctx: SupervisorContext) {
});
{
let guard = state_arc.lock().await;
if let Some(state) = guard.as_ref() {
spawn_event_forwarders(&state.protocol, &events_tx);
let protocol = protocol.lock().await;
if let Some(client) = protocol.as_ref() {
spawn_event_forwarders(client, &events_tx);
}
}
@@ -2346,8 +2217,15 @@ async fn supervisor_loop(ctx: SupervisorContext) {
};
let mut guard = state_arc.lock().await;
if let Some(state) = guard.as_mut() {
let voice_out = state.protocol.voice_out();
if let Some(voice_in) = state.protocol.take_voice_in() {
let (voice_out, voice_in) = {
let protocol = protocol.lock().await;
let client = match protocol.as_ref() {
Some(client) => client,
None => return,
};
(client.voice_out(), client.take_voice_in())
};
if let Some(voice_in) = voice_in {
let gate = chanora_audio::AudioTransmitGate::new(
audio_cfg.ptt_initial,
);
@@ -2633,6 +2511,54 @@ mod tests {
s.disconnect().await.unwrap();
}
#[tokio::test]
async fn disconnect_state_take_releases_inner_lock_before_teardown() {
let inner = Arc::new(Mutex::new(Some(())));
let state = super::take_disconnect_state(&inner).await;
assert_eq!(state, Some(()));
assert!(inner.try_lock().is_ok());
}
#[tokio::test]
async fn supervisor_join_returns_after_shutdown_timeout() {
let handle = tokio::spawn(async {
std::future::pending::<()>().await;
});
let start = std::time::Instant::now();
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
assert!(start.elapsed() < Duration::from_millis(100));
}
#[tokio::test]
async fn supervisor_shutdown_timeout_aborts_pending_task() {
struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for DropNotice {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
let _notice = DropNotice(Some(dropped_tx));
std::future::pending::<()>().await;
});
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
tokio::time::timeout(Duration::from_millis(100), dropped_rx)
.await
.expect("pending supervisor task should be aborted")
.expect("drop notice should be delivered");
}
#[test]
fn signature_detects_in_channel_move() {
use chanora_protocol::{ChannelInfo, ClientInfo};
@@ -2730,6 +2656,38 @@ mod tests {
);
}
#[test]
fn empty_poke_messages_are_dispatchable() {
assert!(super::should_dispatch_text_message(
"",
&MessageTarget::Poke(42)
));
assert!(super::should_dispatch_text_message(
" \t ",
&MessageTarget::Poke(42)
));
}
#[test]
fn empty_non_poke_messages_remain_suppressed() {
assert!(!super::should_dispatch_text_message(
"",
&MessageTarget::Server
));
assert!(!super::should_dispatch_text_message(
" ",
&MessageTarget::Channel
));
assert!(!super::should_dispatch_text_message(
"",
&MessageTarget::Client(42)
));
assert!(super::should_dispatch_text_message(
"hello",
&MessageTarget::Channel
));
}
#[tokio::test]
async fn empty_address_is_rejected() {
let s = ChanoraSession::new();
@@ -0,0 +1,72 @@
use std::collections::VecDeque;
/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
pub(crate) struct NetworkDiagnostics {
/// Total count of connects (including the initial one).
connect_count: u64,
/// Count of disconnects (graceful + loss).
disconnect_count: u64,
/// Recent loss reasons (last 8, ring buffer).
loss_reasons: VecDeque<String>,
}
impl NetworkDiagnostics {
pub(crate) fn record_connect(&mut self) {
self.connect_count = self.connect_count.saturating_add(1);
}
pub(crate) fn record_loss(&mut self, reason: &str) {
self.disconnect_count = self.disconnect_count.saturating_add(1);
if self.loss_reasons.len() >= 8 {
self.loss_reasons.pop_front();
}
self.loss_reasons.push_back(reason.to_string());
}
pub(crate) fn summary(&self) -> String {
let mut s = format!(
"connects: {}\ndisconnects: {}\n",
self.connect_count, self.disconnect_count
);
if !self.loss_reasons.is_empty() {
s.push_str(&format!(
"loss_reasons: [{}]\n",
self.loss_reasons
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ")
));
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_diagnostics_keeps_last_eight_loss_reasons() {
let mut diagnostics = NetworkDiagnostics::default();
for i in 0..10 {
diagnostics.record_loss(&format!("loss-{i}"));
}
assert_eq!(diagnostics.disconnect_count, 10);
assert_eq!(diagnostics.loss_reasons.len(), 8);
assert_eq!(
diagnostics.loss_reasons.front().map(String::as_str),
Some("loss-2")
);
assert_eq!(
diagnostics.loss_reasons.back().map(String::as_str),
Some("loss-9")
);
assert!(diagnostics.summary().contains(
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
));
}
}
+50
View File
@@ -0,0 +1,50 @@
use std::env;
use std::path::PathBuf;
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
#[tokio::test]
async fn get_avatar_returns_cached_bytes_without_connection() {
let tmp = mktemp("chanora_core_avatar_cache_test");
let session = chanora_core::ChanoraSession::new();
session.init_cache(tmp.to_str().unwrap()).await.unwrap();
let cache = chanora_cache::BlobCache::new(&tmp, 100 * 1024 * 1024).unwrap();
let hash = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
cache
.put(chanora_cache::PREFIX_AVATAR, hash, b"avatar-bytes")
.await
.unwrap();
let bytes = session
.get_avatar(hash, "client-uid")
.await
.unwrap()
.unwrap();
assert_eq!(bytes, b"avatar-bytes");
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn get_avatar_without_cache_or_connection_returns_not_connected() {
let session = chanora_core::ChanoraSession::new();
let err = session
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client-uid")
.await
.unwrap_err();
assert!(matches!(err, chanora_core::CoreError::NotConnected));
}
fn mktemp(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = env::temp_dir()
.join(label)
.join(format!("{}-{nanos}", process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
+1 -1
View File
@@ -51,7 +51,7 @@ coreaudio-rs = "0.14"
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
dispatch2 = "0.3"
[target.'cfg(not(target_os = "ios"))'.dependencies]
[target.'cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))'.dependencies]
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
[target.'cfg(target_os = "android")'.dependencies]
@@ -0,0 +1,124 @@
use std::sync::Arc;
use crossbeam::queue::ArrayQueue;
/// Fixed-capacity PCM handoff from the Android render producer task to
/// the Oboe output callback.
pub(crate) struct AndroidRenderRing {
frames: Arc<ArrayQueue<[f32; 2]>>,
}
impl AndroidRenderRing {
pub(crate) fn new(capacity: usize) -> Self {
Self {
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
}
}
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
AndroidRenderRingProducer {
frames: Arc::clone(&self.frames),
}
}
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
AndroidRenderRingConsumer {
frames: Arc::clone(&self.frames),
}
}
}
pub(crate) struct AndroidRenderRingProducer {
frames: Arc<ArrayQueue<[f32; 2]>>,
}
impl AndroidRenderRingProducer {
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
for frame in samples.chunks_exact(2) {
let stereo_frame = [frame[0], frame[1]];
if self.frames.push(stereo_frame).is_err() {
let _ = self.frames.pop();
let _ = self.frames.push(stereo_frame);
}
}
}
}
pub(crate) struct AndroidRenderRingConsumer {
frames: Arc<ArrayQueue<[f32; 2]>>,
}
impl AndroidRenderRingConsumer {
#[cfg(test)]
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
let mut chunks = out.chunks_exact_mut(2);
for frame_out in &mut chunks {
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
frame_out.copy_from_slice(&frame);
}
for sample in chunks.into_remainder() {
*sample = 0.0;
}
}
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
for frame_out in out {
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
*frame_out = (frame[0], frame[1]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn producer_drops_oldest_samples_when_ring_is_full() {
let ring = AndroidRenderRing::new(4);
let producer = ring.producer();
let consumer = ring.consumer();
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
producer.push_frame_lossy(&[5.0, 6.0]);
let mut out = [0.0; 4];
consumer.drain_into_zero_filling(&mut out);
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
}
#[test]
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
let ring = AndroidRenderRing::new(4);
let producer = ring.producer();
let consumer = ring.consumer();
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
let mut odd_out = [9.0];
consumer.drain_into_zero_filling(&mut odd_out);
assert_eq!(odd_out, [0.0]);
producer.push_frame_lossy(&[3.0, 30.0]);
let mut out = [0.0; 4];
consumer.drain_into_zero_filling(&mut out);
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
}
#[test]
fn consumer_zero_fills_tail_on_underrun() {
let ring = AndroidRenderRing::new(4);
let producer = ring.producer();
let consumer = ring.consumer();
producer.push_frame_lossy(&[0.25, -0.25]);
let mut out = [9.0; 4];
consumer.drain_into_zero_filling(&mut out);
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
}
}
+248 -241
View File
@@ -53,7 +53,6 @@ use crate::mobile_voice_backend::{
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
SharingModeChoice, VoiceAudioParams,
};
use chanora_protocol::OutPacket;
use tsclientlib::audio::AudioHandler;
use crate::{engine::SessionAudioId, AudioError};
@@ -86,40 +85,11 @@ use crate::processor::AudioProcessor;
const RENDER_REF_SLOTS: usize = 4;
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
struct RenderReferenceBuffer {
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
write_idx: std::sync::atomic::AtomicUsize,
}
impl RenderReferenceBuffer {
fn new() -> Arc<Self> {
Arc::new(Self {
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
write_idx: std::sync::atomic::AtomicUsize::new(0),
})
}
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
let idx = self.write_idx.load(Ordering::Relaxed);
unsafe {
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
as *mut [f32; RENDER_REF_SAMPLES];
(*slot).copy_from_slice(frame);
}
self.write_idx
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
}
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
let wi = self.write_idx.load(Ordering::Relaxed);
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
self.buf[ri]
}
}
unsafe impl Send for RenderReferenceBuffer {}
unsafe impl Sync for RenderReferenceBuffer {}
type RenderReferenceBuffer =
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
@@ -138,9 +108,8 @@ struct AndroidCaptureState {
encoder: OpusEncoder,
pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
@@ -164,7 +133,7 @@ struct AndroidCaptureState {
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
@@ -188,9 +157,12 @@ impl AndroidCaptureState {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::start_out_packet_worker(
voice_out_tx,
frames_sent.clone(),
"android",
)?,
transmit_active,
frames_sent,
mic_gain,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
@@ -221,8 +193,10 @@ impl AndroidCaptureState {
self.audio_processing_stats
.record_callback_frames(samples.len() as u64);
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
let resampled = self.resample_capture_to_48k(samples);
self.resample_capture_to_48k(samples);
let resampled = std::mem::take(&mut self.resample_scratch);
self.ingest_48k_i16(&resampled);
self.resample_scratch = resampled;
return;
}
self.ingest_48k_i16(samples);
@@ -241,6 +215,7 @@ impl AndroidCaptureState {
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
let frame = self.pending_10ms;
self.process_10ms_capture_frame(&frame);
self.encode_complete_20ms_frames();
self.pending_10ms_len = 0;
}
}
@@ -250,6 +225,10 @@ impl AndroidCaptureState {
return;
}
self.encode_complete_20ms_frames();
}
fn encode_complete_20ms_frames(&mut self) {
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
@@ -258,7 +237,6 @@ impl AndroidCaptureState {
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.frames_sent,
&self.opus_out,
len,
|| {
@@ -286,35 +264,18 @@ impl AndroidCaptureState {
}
}
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
if samples.is_empty() {
return Vec::new();
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
let result = crate::capture_resampler::resample_capture_to_48k(
samples,
self.input_sample_rate_hz,
&mut self.resample_pos,
&mut self.resample_last,
&mut self.resample_scratch,
);
if result.dropped {
self.audio_processing_stats.increment_callback_xrun();
}
self.resample_scratch.clear();
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
let mut pos = self.resample_pos;
while pos < samples.len() as f64 {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let a = if i <= 0 {
self.resample_last as f64
} else {
samples[(i - 1) as usize] as f64
};
let b = if i < samples.len() as isize {
samples[i as usize] as f64
} else {
a
};
let value = (a + frac * (b - a))
.round()
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
self.resample_scratch.push(value);
pos += ratio;
}
self.resample_pos = pos - samples.len() as f64;
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
self.resample_scratch.clone()
result.output_len
}
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
@@ -393,15 +354,9 @@ impl AndroidCaptureState {
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
let path = crate::vad::silero_model_bundle_path();
self.silero_vad_worker =
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
if self.silero_vad_worker.is_none() {
warn!(
target: "chanora_audio",
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
);
}
self.silero_vad_worker = None;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
}
_ => {
self.silero_vad_worker = None;
@@ -444,7 +399,10 @@ impl AndroidCaptureState {
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
@@ -472,21 +430,19 @@ impl AndroidCaptureState {
return;
}
let gain = self.mic_gain;
if (gain - 1.0).abs() < f32::EPSILON {
self.pcm_accum
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
} else {
self.pcm_accum.extend(frame.iter().copied().map(|s| {
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}));
if crate::capture_accumulator::append_processed_i16_bounded(
&mut self.pcm_accum,
&frame,
self.mic_gain,
) {
self.audio_processing_stats.increment_callback_xrun();
}
}
}
struct InputCallback {
state: Arc<Mutex<AndroidCaptureState>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
event_tx: BackendEventTx,
}
@@ -498,9 +454,13 @@ impl AudioInputCallback for InputCallback {
_stream: &mut dyn AudioInputStreamSafe,
frames: &[i16],
) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| {
if let Ok(mut s) = self.state.lock() {
s.ingest_i16(frames);
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
Ok(mut s) => s.ingest_i16(frames),
Err(std::sync::TryLockError::WouldBlock) => {
self.audio_processing_stats.increment_callback_xrun();
}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
}
}));
DataCallbackResult::Continue
@@ -522,8 +482,7 @@ impl AudioInputCallback for InputCallback {
// writes stereo f32 directly to the Oboe output buffer.
struct OutputCallback {
handler: AudioHandler<SessionAudioId>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
event_tx: BackendEventTx,
@@ -542,47 +501,39 @@ impl AudioOutputCallback for OutputCallback {
frames: &mut [(f32, f32)],
) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| {
let buf: &mut [f32] =
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
for s in buf.iter_mut() {
*s = 0.0;
}
for cmd in self.event_consumer.drain_controls() {
match cmd {
AudioCommand::SetVolume(id, vol) => {
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
q.volume = vol;
}
}
AudioCommand::RemoveClient(id) => {
self.handler.get_mut_queues().remove(&id);
}
}
}
for pkt in self.event_consumer.drain_packets(50) {
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
debug!(target: "chanora_audio", error = %e, "decode failed");
}
}
let _ = self.handler.fill_buffer(buf);
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
let muted = self.output_muted.load(Ordering::Relaxed);
if muted {
for s in buf.iter_mut() {
*s = 0.0;
for frame in frames.iter_mut() {
*frame = (0.0, 0.0);
}
} else if gain != 1.0 {
for s in buf.iter_mut() {
*s *= gain;
for (left, right) in frames.iter_mut() {
*left *= gain;
*right *= gain;
}
}
let mut sum_squares = 0.0_f32;
for (left, right) in frames.iter() {
sum_squares += left * left + right * right;
}
let sample_count = frames.len() * 2;
let dbfs = if sample_count == 0 {
-120.0
} else {
let rms = (sum_squares / sample_count as f32).sqrt();
if rms <= 0.000_001 {
-120.0
} else {
20.0 * rms.log10()
}
};
self.audio_processing_stats
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
.update_render(dbfs, frames.len() as u32);
for chunk in buf.chunks_exact(2) {
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
for (left, right) in frames.iter() {
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
self.pending_render_ref_len += 1;
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
self.render_reference.write(&self.pending_render_ref);
@@ -614,6 +565,7 @@ impl AudioOutputCallback for OutputCallback {
pub struct AndroidVoiceUnit {
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
render_producer_shutdown: Arc<AtomicBool>,
// Recorded achieved values (SDD-112).
input_perf: AchievedPerformanceMode,
@@ -635,11 +587,13 @@ pub struct AndroidVoiceUnit {
#[derive(Default)]
struct HardwareEffectHandles {
aec: Option<jni::objects::GlobalRef>,
ns: Option<jni::objects::GlobalRef>,
agc: Option<jni::objects::GlobalRef>,
aec: Option<AndroidGlobalObject>,
ns: Option<AndroidGlobalObject>,
agc: Option<AndroidGlobalObject>,
}
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
impl AndroidVoiceUnit {
/// Open the input + output streams (SDD-111 + SDD-112) and,
/// once a session id is available, attach SDD-113 hardware
@@ -706,6 +660,7 @@ impl AndroidVoiceUnit {
let input_cb = InputCallback {
state: capture_state.clone(),
audio_processing_stats: audio_processing_stats.clone(),
event_tx: event_tx.clone(),
};
let input_builder = input_builder.set_callback(input_cb);
@@ -722,7 +677,12 @@ impl AndroidVoiceUnit {
error = ?e,
"android: primary input stream open failed; entering fallback ladder"
);
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
match Self::open_input_fallback(
cfg,
&event_tx,
capture_state.clone(),
audio_processing_stats.clone(),
) {
Ok(s) => Some(s),
Err(fallback_err) => {
warn!(
@@ -789,9 +749,10 @@ impl AndroidVoiceUnit {
let render_ref_for_output = render_ref_buf.clone();
let event_queue = params.event_producer.queue();
let render_ring =
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
let output_cb = OutputCallback {
handler: params.handler,
event_consumer: AudioEventQueue::consumer(&event_queue),
pcm_consumer: render_ring.consumer(),
output_gain: params.output_gain.clone(),
output_muted: params.output_muted.clone(),
event_tx: event_tx.clone(),
@@ -813,8 +774,7 @@ impl AndroidVoiceUnit {
Self::open_output_fallback(
cfg,
&event_tx,
AudioHandler::new(),
AudioEventQueue::consumer(&event_queue),
render_ring.consumer(),
params.output_gain.clone(),
params.output_muted.clone(),
audio_processing_stats.clone(),
@@ -822,6 +782,11 @@ impl AndroidVoiceUnit {
)?
}
};
let render_producer_shutdown = Self::spawn_render_producer(
params.handler,
AudioEventQueue::consumer(&event_queue),
render_ring.producer(),
);
let output_frames_per_burst = output_stream.get_frames_per_burst();
if output_frames_per_burst > 0 {
@@ -978,6 +943,7 @@ impl AndroidVoiceUnit {
Ok(Self {
input: input_stream,
output: Some(output_stream),
render_producer_shutdown,
input_perf,
input_share,
output_perf,
@@ -995,6 +961,7 @@ impl AndroidVoiceUnit {
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
capture_state: Arc<Mutex<AndroidCaptureState>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
// SDD-112 items 6 & 7: explore (preset × sharing) independently
// via the pure helpers in `mobile_voice_backend`. Primary
@@ -1031,6 +998,7 @@ impl AndroidVoiceUnit {
};
let cb = InputCallback {
state: capture_state.clone(),
audio_processing_stats: audio_processing_stats.clone(),
event_tx: event_tx.clone(),
};
let builder = AudioStreamBuilder::default()
@@ -1066,16 +1034,14 @@ impl AndroidVoiceUnit {
fn open_output_fallback(
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
handler: AudioHandler<SessionAudioId>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback {
handler,
event_consumer,
pcm_consumer,
output_gain,
output_muted,
event_tx: event_tx.clone(),
@@ -1100,6 +1066,50 @@ impl AndroidVoiceUnit {
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
}
fn spawn_render_producer(
mut handler: AudioHandler<SessionAudioId>,
event_consumer: crate::audio_event_queue::AudioEventConsumer,
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
) -> Arc<AtomicBool> {
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_for_task = shutdown.clone();
tokio::spawn(async move {
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
if shutdown_for_task.load(Ordering::Relaxed) {
break;
}
for cmd in event_consumer.drain_controls() {
match cmd {
AudioCommand::SetVolume(id, vol) => {
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
q.volume = vol;
}
}
AudioCommand::RemoveClient(id) => {
handler.get_mut_queues().remove(&id);
}
}
}
for pkt in event_consumer.drain_packets(50) {
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
debug!(target: "chanora_audio", error = %e, "decode failed");
}
}
pull_scratch.fill(0.0);
let _ = handler.fill_buffer(&mut pull_scratch);
pcm_producer.push_frame_lossy(&pull_scratch);
}
});
shutdown
}
/// Clone of the event sender, for JNI focus / SCO listeners
/// registered on the engine's behalf.
pub fn event_sender(&self) -> BackendEventTx {
@@ -1151,6 +1161,7 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
fn close(&mut self) -> Result<(), BackendError> {
// SDD-115 reverse order: release hardware effects FIRST,
// then close streams.
self.render_producer_shutdown.store(true, Ordering::Relaxed);
release_hardware_effects(&mut self.hw_effects);
self.stop().ok();
// Dropping the Option drops the underlying AudioStreamAsync
@@ -1208,6 +1219,7 @@ impl Drop for AndroidVoiceUnit {
// Wrap in catch_unwind so a panic during Drop cannot unwind
// into the JVM (SDD-115 callback safety).
let _ = catch_unwind(AssertUnwindSafe(|| {
self.render_producer_shutdown.store(true, Ordering::Relaxed);
release_hardware_effects(&mut self.hw_effects);
// SDD-116: clear the diagnostics slot on Drop too.
clear_android_audio_diagnostics();
@@ -1287,33 +1299,11 @@ fn attach_hardware_effects_inner(
session_id: AudioSessionId,
effects: &crate::AudioEffects,
) -> HardwareEffectHandles {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
warn!(
target: "chanora_audio",
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
);
return HardwareEffectHandles::default();
}
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
Ok(v) => v,
Err(e) => {
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
return HardwareEffectHandles::default();
}
};
let mut env = match jvm.attach_current_thread() {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
return HardwareEffectHandles::default();
}
};
with_android_env("hardware effects", |env| {
let mut handles = HardwareEffectHandles::default();
if effects.aec {
handles.aec = create_effect(
&mut env,
env,
"android/media/audiofx/AcousticEchoCanceler",
session_id,
"AEC",
@@ -1321,7 +1311,7 @@ fn attach_hardware_effects_inner(
}
if effects.noise_suppression {
handles.ns = create_effect(
&mut env,
env,
"android/media/audiofx/NoiseSuppressor",
session_id,
"NS",
@@ -1329,20 +1319,51 @@ fn attach_hardware_effects_inner(
}
if effects.agc {
handles.agc = create_effect(
&mut env,
env,
"android/media/audiofx/AutomaticGainControl",
session_id,
"AGC",
);
}
handles
})
.unwrap_or_default()
}
fn with_android_env<R>(
operation: &str,
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
) -> Option<R> {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
warn!(
target: "chanora_audio",
operation,
"android: ndk_context vm null; JNI call skipped"
);
return None;
}
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
Ok(value) => Some(value),
Err(e) => {
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
None
}
}
}
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
/// class before calling `create(int)`. Returns `false` on any JNI
/// failure so the caller engages the software fallback.
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
match env.call_static_method(
class,
jni::jni_str!("isAvailable"),
jni::jni_sig!("()Z"),
&[],
) {
Ok(v) => match v.z() {
Ok(b) => b,
Err(e) => {
@@ -1360,14 +1381,14 @@ fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, labe
}
fn create_effect(
env: &mut jni::JNIEnv,
env: &mut jni::Env<'_>,
fqcn: &str,
session_id: AudioSessionId,
label: &str,
) -> Option<jni::objects::GlobalRef> {
) -> Option<AndroidGlobalObject> {
use jni::objects::JValue;
// Class.create(int) -> ClassInstance|null
let class = match env.find_class(fqcn) {
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
Ok(c) => c,
Err(e) => {
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
@@ -1383,10 +1404,18 @@ fn create_effect(
);
return None;
}
let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};"))
{
Ok(sig) => sig,
Err(e) => {
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed");
return None;
}
};
let inst = match env.call_static_method(
&class,
"create",
&format!("(I)L{fqcn};"),
jni::jni_str!("create"),
create_sig.method_signature(),
&[JValue::Int(session_id)],
) {
Ok(v) => match v.l() {
@@ -1411,8 +1440,8 @@ fn create_effect(
// setEnabled(true) -> int (success code)
if let Err(e) = env.call_method(
&inst,
"setEnabled",
"(Z)I",
jni::jni_str!("setEnabled"),
jni::jni_sig!("(Z)I"),
&[JValue::Bool(jni::sys::JNI_TRUE)],
) {
let _ = env.exception_clear();
@@ -1448,34 +1477,28 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
if aec.is_none() && ns.is_none() && agc.is_none() {
return;
}
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
return;
}
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
Ok(v) => v,
Err(_) => return,
};
let mut env = match jvm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let _ = with_android_env("release hardware effects", |env| {
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
if let Some(g) = effect {
let _ = env.call_method(
g.as_obj(),
"setEnabled",
"(Z)I",
jni::jni_str!("setEnabled"),
jni::jni_sig!("(Z)I"),
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
);
let _ = env.exception_clear();
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
let _ = env.exception_clear();
env.exception_clear();
let _ = env.call_method(
g.as_obj(),
jni::jni_str!("release"),
jni::jni_sig!("()V"),
&[],
);
env.exception_clear();
drop(g);
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
}
}
});
}
// --- Process-global BackendEvent sender for JNI callbacks --------
@@ -1550,7 +1573,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
fn call_voice_service_static(method: &str) -> bool {
use jni::objects::{JObject, JValue};
let ctx = ndk_context::android_context();
if ctx.vm().is_null() || ctx.context().is_null() {
if ctx.context().is_null() {
warn!(
target: "chanora_audio",
method,
@@ -1558,34 +1581,19 @@ fn call_voice_service_static(method: &str) -> bool {
);
return false;
}
// SAFETY: vm/context populated by chanora_bridge::android_init at
// JNI_OnLoad + initChanoraContext; both pointers are valid for
// the process lifetime.
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
Ok(v) => v,
Err(e) => {
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
return false;
}
};
let mut env = match jvm.attach_current_thread() {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
return false;
}
};
with_android_env("voice foreground service", |env| {
// SAFETY: ndk_context::context() is the application Context
// jobject; valid global ref for process lifetime.
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
Some(c) => c,
None => return false,
};
match env.call_static_method(
&class,
method,
"(Landroid/content/Context;)V",
jni::strings::JNIString::new(method),
jni::jni_sig!("(Landroid/content/Context;)V"),
&[JValue::Object(&context_obj)],
) {
Ok(_) => {
@@ -1593,19 +1601,21 @@ fn call_voice_service_static(method: &str) -> bool {
true
}
Err(e) => {
let _ = env.exception_clear();
env.exception_clear();
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
false
}
}
})
.unwrap_or(false)
}
fn load_app_class<'local>(
env: &mut jni::JNIEnv<'local>,
env: &mut jni::Env<'local>,
context_obj: &jni::objects::JObject<'local>,
slash_name: &str,
) -> Option<jni::objects::JClass<'local>> {
match env.find_class(slash_name) {
match env.find_class(jni::strings::JNIString::new(slash_name)) {
Ok(c) => return Some(c),
Err(e) => {
let _ = env.exception_clear();
@@ -1616,8 +1626,8 @@ fn load_app_class<'local>(
let loader = match env
.call_method(
context_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
jni::jni_str!("getClassLoader"),
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
&[],
)
.and_then(|v| v.l())
@@ -1642,13 +1652,20 @@ fn load_app_class<'local>(
match env
.call_method(
&loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
jni::jni_str!("loadClass"),
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
&[jni::objects::JValue::Object(&class_name_obj)],
)
.and_then(|v| v.l())
{
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
Ok(class_obj) => match env.cast_local::<jni::objects::JClass>(class_obj) {
Ok(class) => Some(class),
Err(e) => {
env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object");
None
}
},
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
@@ -1679,7 +1696,7 @@ fn load_app_class<'local>(
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
'local,
>(
_env: jni::JNIEnv<'local>,
_env: jni::EnvUnowned<'local>,
_class: jni::objects::JClass<'local>,
state: jni::sys::jint,
) {
@@ -1717,7 +1734,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
'local,
>(
_env: jni::JNIEnv<'local>,
_env: jni::EnvUnowned<'local>,
_class: jni::objects::JClass<'local>,
state: jni::sys::jint,
) {
@@ -1766,7 +1783,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
use jni::objects::{JObject, JValue};
let ctx = ndk_context::android_context();
if ctx.vm().is_null() || ctx.context().is_null() {
if ctx.context().is_null() {
warn!(
target: "chanora_audio",
class = fqcn,
@@ -1775,29 +1792,17 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
);
return false;
}
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
Ok(v) => v,
Err(e) => {
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
return false;
}
};
let mut env = match jvm.attach_current_thread() {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
return false;
}
};
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let class = match load_app_class(&mut env, &context_obj, fqcn) {
with_android_env("static context call", |env| {
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
let class = match load_app_class(env, &context_obj, fqcn) {
Some(c) => c,
None => return false,
};
match env.call_static_method(
&class,
method,
"(Landroid/content/Context;)V",
jni::strings::JNIString::new(method),
jni::jni_sig!("(Landroid/content/Context;)V"),
&[JValue::Object(&context_obj)],
) {
Ok(_) => {
@@ -1805,9 +1810,11 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
true
}
Err(e) => {
let _ = env.exception_clear();
env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
false
}
}
})
.unwrap_or(false)
}
+5 -45
View File
@@ -56,10 +56,8 @@ impl AudioRoute {
/// iOS voice-processing mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IosVoiceProcessingMode {
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
/// Apple VoiceProcessingIO owns AEC/NS/AGC.
PlatformVoiceProcessing,
/// Experimental raw capture-processing path.
SonoraExperimental,
}
/// Processing backend selected by policy/config.
@@ -195,28 +193,20 @@ impl AudioProcessingConfig {
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
&& (self.processing_backend == AudioBackend::Sonora
if self.processing_backend == AudioBackend::Sonora
|| self.processing_backend == AudioBackend::WebrtcApm
|| self.aec == EffectOwner::Sonora
|| self.aec == EffectOwner::WebrtcApm
|| self.ns == EffectOwner::Sonora
|| self.ns == EffectOwner::WebrtcApm
|| self.agc == EffectOwner::Sonora
|| self.agc == EffectOwner::WebrtcApm)
|| self.agc == EffectOwner::WebrtcApm
{
return Err(AudioError::InvalidAudioProcessingConfig(
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
.to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental
&& self.processing_backend != AudioBackend::WebrtcApm
{
return Err(AudioError::InvalidAudioProcessingConfig(
"ios raw processing mode requires the WebRTC APM backend".to_string(),
));
}
Ok(())
}
@@ -262,34 +252,6 @@ mod tests {
assert!(config.validate_for_ios().is_err());
}
#[test]
fn raw_processing_allows_full_webrtc_apm_chain() {
let config = AudioProcessingConfig {
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
processing_backend: AudioBackend::WebrtcApm,
aec: EffectOwner::WebrtcApm,
ns: EffectOwner::WebrtcApm,
agc: EffectOwner::WebrtcApm,
..AudioProcessingConfig::default()
};
assert!(config.validate_for_ios().is_ok());
}
#[test]
fn raw_processing_rejects_non_webrtc_apm_backend() {
let config = AudioProcessingConfig {
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
processing_backend: AudioBackend::PlatformVoiceProcessing,
aec: EffectOwner::WebrtcApm,
ns: EffectOwner::WebrtcApm,
agc: EffectOwner::WebrtcApm,
..AudioProcessingConfig::default()
};
assert!(config.validate_for_ios().is_err());
}
#[test]
fn disable_failed_vad_backend_demotes_to_webrtc() {
let mut config = AudioProcessingConfig {
@@ -404,10 +366,8 @@ impl Default for SharedAudioProcessingStats {
}
impl SharedAudioProcessingStats {
/// Store the raw input dBFS level (desktop capture path).
/// Mobile platforms use [`Self::update_capture`] instead, which
/// also records VAD state; this lighter method is for the cpal
/// capture path that has no VAD pipeline.
/// Store the raw input dBFS level for capture paths that do not
/// update the full processing/VAD snapshot on this callback.
pub fn set_input_dbfs(&self, dbfs: f32) {
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
}
@@ -0,0 +1,118 @@
pub(crate) fn append_processed_i16_bounded(
pcm_accum: &mut Vec<i16>,
frame: &[f32],
gain: f32,
) -> bool {
if (gain - 1.0).abs() < f32::EPSILON {
for src in frame.iter().copied() {
if pcm_accum.len() == pcm_accum.capacity() {
return true;
}
pcm_accum.push(crate::frame::f32_to_i16(src));
}
} else {
for src in frame.iter().copied() {
if pcm_accum.len() == pcm_accum.capacity() {
return true;
}
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
}
}
false
}
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
for src in frame.iter().copied() {
if pcm_accum.len() == pcm_accum.capacity() {
return true;
}
pcm_accum.push(src);
}
false
}
#[cfg(test)]
mod tests {
use super::{append_i16_bounded, append_processed_i16_bounded};
#[test]
fn append_processed_i16_bounded_does_not_grow_when_full() {
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
assert!(dropped);
assert_eq!(accum.len(), warmed_capacity);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
assert!(!dropped);
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_i16_bounded(&mut accum, &frame);
assert!(dropped);
assert_eq!(accum.len(), warmed_capacity);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_i16_bounded_preserves_expected_10ms_append() {
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
let dropped = append_i16_bounded(&mut accum, &frame);
assert!(!dropped);
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
#[test]
fn append_i16_bounded_preserves_full_vad_preroll_window() {
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
let warmed_capacity = accum.capacity();
let warmed_ptr = accum.as_ptr();
for _ in 0..16 {
assert!(!append_i16_bounded(&mut accum, &frame));
}
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
assert!(append_i16_bounded(&mut accum, &frame));
assert_eq!(accum.len(), warmed_capacity);
assert_eq!(accum.capacity(), warmed_capacity);
assert_eq!(accum.as_ptr(), warmed_ptr);
}
}
@@ -0,0 +1,105 @@
pub(crate) struct CaptureResampleResult {
pub(crate) output_len: usize,
pub(crate) dropped: bool,
}
pub(crate) fn resample_capture_to_48k(
samples: &[i16],
input_sample_rate_hz: u32,
resample_pos: &mut f64,
resample_last: &mut i16,
scratch: &mut Vec<i16>,
) -> CaptureResampleResult {
scratch.clear();
if samples.is_empty() {
return CaptureResampleResult {
output_len: 0,
dropped: false,
};
}
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
let mut pos = *resample_pos;
let mut dropped = false;
while pos < samples.len() as f64 {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let a = if i <= 0 {
*resample_last as f64
} else {
samples[(i - 1) as usize] as f64
};
let b = if i < samples.len() as isize {
samples[i as usize] as f64
} else {
a
};
let value = (a + frac * (b - a))
.round()
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
if scratch.len() < scratch.capacity() {
scratch.push(value);
} else {
dropped = true;
}
pos += ratio;
}
*resample_pos = pos - samples.len() as f64;
*resample_last = *samples.last().unwrap_or(resample_last);
CaptureResampleResult {
output_len: scratch.len(),
dropped,
}
}
#[cfg(test)]
mod android_voice_unit_resampler_tests {
use super::resample_capture_to_48k;
#[test]
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
let mut pos = 0.0;
let mut last = 0_i16;
let mut scratch = Vec::with_capacity(960);
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
let first_len = first.output_len;
assert_eq!(first_len, 960);
assert!(!first.dropped);
assert_eq!(scratch.len(), first_len);
let warmed_capacity = scratch.capacity();
let warmed_ptr = scratch.as_ptr();
for _ in 0..8 {
let result =
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
let len = result.output_len;
assert_eq!(len, scratch.len());
assert!(len >= 959 && len <= 960);
assert!(!result.dropped);
assert_eq!(scratch.capacity(), warmed_capacity);
assert_eq!(scratch.as_ptr(), warmed_ptr);
}
}
#[test]
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
let mut pos = 0.0;
let mut last = 0_i16;
let mut scratch = Vec::with_capacity(960);
let warmed_capacity = scratch.capacity();
let warmed_ptr = scratch.as_ptr();
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
assert_eq!(result.output_len, warmed_capacity);
assert!(result.dropped);
assert_eq!(scratch.len(), warmed_capacity);
assert_eq!(scratch.capacity(), warmed_capacity);
assert_eq!(scratch.as_ptr(), warmed_ptr);
assert_eq!(pos, 0.0);
assert_eq!(last, *samples.last().unwrap());
}
}
File diff suppressed because it is too large Load Diff
-558
View File
@@ -1,558 +0,0 @@
//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode.
//!
//! Provides an alternative to `ios_voice_unit.rs` for the
//! `SonoraExperimental` processing mode. Instead of
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
//! disabled so WebRTC APM can own the full signal path.
//!
//! ## Hard invariants enforced here
//!
//! * INV_009: Rust AEC only active when platform AEC is disabled.
//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive.
//! * INV_011: Software AEC backend receives both capture and render-reference.
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
//! before playout.
//!
//! ## Fallback
//!
//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit`
//! (VPIO) and logs the error.
//!
//! ## Status
//!
//! Experimental / disabled by default. Only activated when the user
//! explicitly selects `SonoraExperimental` mode via the bridge API.
//!
//! ## Platform
//!
//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK.
//! This module is gated to `target_os = "ios"`.
#[cfg(target_os = "ios")]
pub use inner::IosRawUnit;
#[cfg(target_os = "ios")]
mod inner {
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder;
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{info, warn};
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::processor::AudioProcessor;
use crate::AudioError;
use chanora_protocol::OutPacket;
const SAMPLE_RATE_HZ: f64 = 48_000.0;
// ------------------------------------------------------------------ //
// Render-reference ring buffer //
// ------------------------------------------------------------------ //
/// 4-slot ring buffer shared between the render callback (writer) and
/// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms
/// = 40 ms of headroom.
///
/// If the capture callback runs before the render callback has written
/// a frame it reads zeros (silence reference), which is safe — Sonora
/// AEC3 simply skips cancellation for that frame.
struct RenderReferenceBuffer {
buf: Box<[[f32; 480]; 4]>,
write_idx: std::sync::atomic::AtomicUsize,
}
impl RenderReferenceBuffer {
fn new() -> Arc<Self> {
Arc::new(Self {
buf: Box::new([[0.0; 480]; 4]),
write_idx: std::sync::atomic::AtomicUsize::new(0),
})
}
/// Write one 10 ms render-reference frame. Realtime-safe.
fn write(&self, frame: &[f32; 480]) {
let idx = self.write_idx.load(Ordering::Relaxed);
// SAFETY: only one writer (render callback); torn reads
// are bounded to one frame of AEC degradation.
unsafe {
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
(*slot).copy_from_slice(frame);
}
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
}
/// Read the most recently completed render-reference frame.
fn read_latest(&self) -> [f32; 480] {
let wi = self.write_idx.load(Ordering::Relaxed);
let ri = (wi + 3) % 4;
self.buf[ri]
}
}
// SAFETY: accessed from two audio callback threads; data races are
// bounded to one frame of AEC quality degradation.
unsafe impl Send for RenderReferenceBuffer {}
unsafe impl Sync for RenderReferenceBuffer {}
// ------------------------------------------------------------------ //
// Capture pipeline state //
// ------------------------------------------------------------------ //
struct RawCaptureState {
encoder: OpusEncoder,
pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_coreml_worker: Option<crate::vad::apple_coreml::AppleCoreMlVadWorker>,
current_vad_backend: crate::VadBackend,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
/// Processing config — retained for route-change reloads.
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize,
fallback_warned_backend: Option<crate::VadBackend>,
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
}
impl RawCaptureState {
fn new(
params: &VoiceAudioParams,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
let webrtc_apm_config = params
.audio_processing_config
.lock()
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
.unwrap_or_default();
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: params.voice_out_tx.clone(),
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_coreml_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config: params.audio_processing_config.clone(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
webrtc_apm_config,
)?,
audio_processing_stats: params.audio_processing_stats.clone(),
render_reference,
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
fallback_warned_backend: None,
wav_recorder: None,
})
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
if self.capture_frame_seq < 128 {
tracing::info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"VAD backend warming up; using WebRTC fallback"
);
} else {
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
}
fn ingest_i16(&mut self, samples: &[i16]) {
// Accumulate into 10 ms frames for VAD / Sonora processing.
let mut offset = 0;
while offset < samples.len() {
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(samples.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&samples[offset..offset + take]);
self.pending_10ms_len += take;
offset += take;
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
let frame = self.pending_10ms;
self.process_10ms_capture_frame(&frame);
self.pending_10ms_len = 0;
}
}
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
}
// Encode complete 20 ms Opus frames.
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.frames_sent,
&self.opus_out,
len,
|| {
warn!(
target: "chanora_audio",
"ios raw: voice_out queue full; dropping frame"
);
},
|| {},
);
}
Err(e) => {
tracing::error!(target: "chanora_audio",
error = %e, "ios raw opus encode failed");
}
}
}
}
fn process_10ms_capture_frame(
&mut self,
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
) {
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
*dst = crate::frame::i16_to_f32(src);
}
let input_dbfs = crate::frame::dbfs(&frame);
// WAV tap: raw mic (before processing).
if let Some(ref rec) = self.wav_recorder {
rec.push_raw_mic(&frame);
}
// Feed render reference to WebRTC APM before capture so AEC can adapt.
let render_ref = self.render_reference.read_latest();
self.webrtc_apm_processor.process_render(&render_ref);
self.webrtc_apm_processor.process_capture(&mut frame);
// WAV tap: processed mic (after WebRTC APM).
if let Some(ref rec) = self.wav_recorder {
rec.push_processed_mic(&frame);
}
let voice_activity_mode = self
.voice_activity_selector
.as_ref()
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
.unwrap_or(false);
if !voice_activity_mode {
self.silero_coreml_worker = None;
self.current_vad_backend = crate::VadBackend::Disabled;
self.fallback_warned_backend = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
let (vad_backend, vad_hangover) = self
.audio_processing_config
.try_lock()
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
.unwrap_or((
crate::VadBackend::WebrtcVad,
crate::voice_activity::VAD_HANGOVER_MS,
));
if voice_activity_mode {
self.vad_state.configure(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
}
if voice_activity_mode && vad_backend != self.current_vad_backend {
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
if vad_backend == crate::VadBackend::SileroOnnx {
self.silero_coreml_worker =
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
if self.silero_coreml_worker.is_none() {
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
} else {
self.audio_processing_stats.set_vad_fallback_active(false);
}
} else {
self.silero_coreml_worker = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
self.vad_state.reset();
}
let (vad_probability, active) = if voice_activity_mode {
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
let capture_seq = self.capture_frame_seq;
let mut used_fallback_vad = false;
let vad = if vad_backend == crate::VadBackend::Disabled {
crate::vad::VadOutput {
probability: 1.0,
speech: true,
}
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_coreml_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
(vad.probability, self.vad_state.update(vad.speech))
} else {
(0.0, false)
};
if let Some(sel) = &self.voice_activity_selector {
sel.set_voice_activity_open(voice_activity_mode && active);
}
self.audio_processing_stats.update_capture(
input_dbfs,
crate::frame::dbfs(&frame),
vad_probability,
voice_activity_mode && active,
self.transmit_active.load(Ordering::Relaxed),
);
if !self.transmit_active.load(Ordering::Relaxed) {
return;
}
let gain = self.mic_gain;
if (gain - 1.0).abs() < f32::EPSILON {
self.pcm_accum
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
} else {
self.pcm_accum.extend(frame.iter().copied().map(|s| {
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}));
}
}
}
// ------------------------------------------------------------------ //
// IosRawUnit //
// ------------------------------------------------------------------ //
/// Raw iOS RemoteIO audio unit for the Sonora experimental path.
pub struct IosRawUnit {
unit: AudioUnit,
}
impl IosRawUnit {
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
{
let cfg = params.audio_processing_config.lock().unwrap();
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
return Err(AudioError::InvalidAudioProcessingConfig(
"IosRawUnit requires raw WebRTC APM mode".to_string(),
));
}
}
let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO)
.map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?;
// Enable input on bus 1.
const ENABLE_IO: u32 = 2003;
let enable: u32 = 1;
unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable))
.map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?;
// 48 kHz Int16 mono on both buses.
let fmt = StreamFormat {
sample_rate: SAMPLE_RATE_HZ,
sample_format: SampleFormat::I16,
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
channels: 1,
};
unit.set_stream_format(fmt, Scope::Input, Element::Output)
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?;
unit.set_stream_format(fmt, Scope::Output, Element::Input)
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?;
// Shared render-reference buffer (INV_011 / INV_012).
let render_ref_buf = RenderReferenceBuffer::new();
let render_ref_for_capture = render_ref_buf.clone();
let mut capture_state = RawCaptureState::new(&params, render_ref_for_capture)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
capture_state.ingest_i16(args.data.buffer);
Ok(())
})
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
let handler = params.handler.clone();
let output_gain = params.output_gain.clone();
let output_muted = params.output_muted.clone();
let stats_render = params.audio_processing_stats.clone();
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let out = args.data.buffer;
let n = out.len();
let stereo_n = n * 2;
if scratch.len() < stereo_n {
scratch.resize(stereo_n, 0.0);
}
scratch[..stereo_n].fill(0.0);
match handler.try_lock() {
Ok(mut h) => {
let _ = h.fill_buffer(&mut scratch[..stereo_n]);
}
Err(std::sync::TryLockError::WouldBlock) => {
stats_render.increment_callback_xrun();
}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio",
"AudioHandler poisoned (raw render): {e}");
}
}
// INV_012: copy render reference BEFORE playout.
let mono_n = n.min(480);
let mut ref_frame = [0.0_f32; 480];
crate::voice_render::downmix_stereo_f32_to_mono_f32(
&scratch[..stereo_n],
&mut ref_frame[..mono_n],
);
render_ref_buf.write(&ref_frame);
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
let muted = output_muted.load(Ordering::Relaxed);
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
&scratch[..stereo_n],
out,
gain,
muted,
);
if mix_stats.clipped_samples > 0 {
stats_render.add_clipped_samples(mix_stats.clipped_samples);
}
stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32);
Ok(())
})
.map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?;
unit.initialize()
.map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?;
unit.start()
.map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?;
info!(
target: "chanora_audio",
sample_rate_hz = SAMPLE_RATE_HZ,
"ios RemoteIO (Sonora experimental) started"
);
Ok(Self { unit })
}
/// Restart the unit after a route change (stop → uninit → init → start).
pub fn restart(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?;
self.unit
.uninitialize()
.map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?;
self.unit
.initialize()
.map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?;
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?;
info!(target: "chanora_audio", "ios RemoteIO restarted");
Ok(())
}
/// Pause the unit during an AVAudioSession interruption.
pub fn pause(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("remoteio pause: {e}")))
}
/// Resume the unit after an interruption ends.
pub fn resume(&mut self) -> Result<(), AudioError> {
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("remoteio resume: {e}")))
}
}
impl Drop for IosRawUnit {
fn drop(&mut self) {
if let Err(e) = self.unit.stop() {
warn!(target: "chanora_audio", error = %e,
"ios RemoteIO stop on drop failed");
} else {
info!(target: "chanora_audio", "ios RemoteIO stopped");
}
}
}
}
+76 -137
View File
@@ -66,7 +66,7 @@
//! * AVAudioSession category / mode configuration — Swift owns the
//! session (it must be set up before Flutter loads).
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder;
@@ -75,12 +75,10 @@ use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use crossbeam::queue::ArrayQueue;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::AudioError;
use chanora_protocol::OutPacket;
/// Sample rate every layer above us assumes. Matches the Opus
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
@@ -105,6 +103,15 @@ const INPUT_BUS: Element = Element::Input;
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
const PRE_ROLL_FRAMES: usize = 16;
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
/// growing inside the input callback.
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
/// to this capacity and the remaining output is silence.
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
/// Capture pipeline state owned by the VPIO input callback. The
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
/// downmix or resample needed — VPIO's hardware-side mix-down
@@ -132,10 +139,9 @@ struct IosCaptureState {
/// jitter without reallocating.
pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
@@ -154,7 +160,6 @@ struct IosCaptureState {
pre_roll_count: usize,
pre_roll_flushed: bool,
capture_frame_seq: u64,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
}
impl IosCaptureState {
@@ -162,20 +167,20 @@ impl IosCaptureState {
/// Encoder configuration is the same as cpal-side
/// `try_open_capture` (engine.rs) so audio quality is platform-
/// neutral.
fn new(
params: &VoiceAudioParams,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
) -> Result<Self, AudioError> {
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: params.voice_out_tx.clone(),
voice_out_tx: crate::opus_voice::start_out_packet_worker(
params.voice_out_tx.clone(),
params.frames_sent.clone(),
"ios-vpio",
)?,
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
@@ -193,7 +198,6 @@ impl IosCaptureState {
pre_roll_count: 0,
pre_roll_flushed: false,
capture_frame_seq: 0,
wav_recorder,
})
}
@@ -267,7 +271,6 @@ impl IosCaptureState {
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.frames_sent,
&self.opus_out,
len,
|| {
@@ -298,25 +301,9 @@ impl IosCaptureState {
}
let input_dbfs = crate::frame::dbfs(&frame);
// WAV tap: raw mic (before processing, DIAG_002).
if let Ok(guard) = self.wav_recorder.try_lock() {
if let Some(rec) = guard.as_ref() {
rec.push_raw_mic(&frame);
}
}
// Read config once per frame (try_lock: non-blocking, falls back to
// last-known values if the lock is contended — safe to miss one frame).
let (
run_ns,
run_agc,
run_hpf,
vad_backend,
vad_hangover,
debug_wav_dump_enabled,
route,
processing_backend,
) = self
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
.audio_processing_config
.try_lock()
.map(|cfg| {
@@ -332,8 +319,6 @@ impl IosCaptureState {
cfg.vad_backend,
cfg.vad_hangover_ms,
cfg.debug_wav_dump_enabled,
cfg.route,
cfg.processing_backend,
)
})
.unwrap_or((
@@ -343,10 +328,13 @@ impl IosCaptureState {
crate::VadBackend::WebrtcVad,
crate::voice_activity::VAD_HANGOVER_MS,
false,
crate::AudioRoute::Unknown,
crate::AudioBackend::PlatformVoiceProcessing,
));
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
// path allocates per frame. Debug WAV capture is intentionally disabled
// here until the recorder can hand off preallocated frames.
let _ = debug_wav_dump_enabled;
let voice_activity_mode = self
.voice_activity_selector
.as_ref()
@@ -359,32 +347,13 @@ impl IosCaptureState {
self.audio_processing_stats.set_vad_fallback_active(false);
}
// Switch VAD backend only while VoiceActivity mode is active.
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
if debug_wav_dump_enabled {
if recorder_guard.is_none() {
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
route,
processing_backend,
));
}
} else if let Some(recorder) = recorder_guard.take() {
recorder.stop();
}
}
if voice_activity_mode && vad_backend != self.current_vad_backend {
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
if vad_backend == crate::VadBackend::SileroOnnx {
self.silero_coreml_worker =
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
if self.silero_coreml_worker.is_none() {
self.silero_coreml_worker = None;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
} else {
self.audio_processing_stats.set_vad_fallback_active(false);
}
} else {
self.silero_coreml_worker = None;
self.audio_processing_stats.set_vad_fallback_active(false);
@@ -461,7 +430,10 @@ impl IosCaptureState {
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
@@ -484,13 +456,6 @@ impl IosCaptureState {
transmit_active,
);
// WAV tap: processed mic (after Rust DSP, DIAG_002).
if let Ok(guard) = self.wav_recorder.try_lock() {
if let Some(rec) = guard.as_ref() {
rec.push_processed_mic(&frame);
}
}
// Convert to i16 for accumulation.
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
@@ -528,7 +493,13 @@ impl IosCaptureState {
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
for i in 0..pre_roll_to_emit {
let idx = (oldest + i) % PRE_ROLL_FRAMES;
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
if crate::capture_accumulator::append_i16_bounded(
&mut self.pcm_accum,
&self.pre_roll_buf[idx],
) {
self.audio_processing_stats.increment_callback_xrun();
break;
}
}
} else if !transmit_active {
// Gate closed — reset the flush flag so pre-roll fires again
@@ -540,7 +511,9 @@ impl IosCaptureState {
return;
}
self.pcm_accum.extend_from_slice(&pcm_frame);
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
self.audio_processing_stats.increment_callback_xrun();
}
}
}
@@ -694,9 +667,7 @@ impl IosVoiceUnit {
Element::Output,
Some(&ducking_config),
) {
tracing::debug!(
"vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}"
);
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
}
// Note: we keep VPIO's voice processing chain ENABLED
@@ -748,18 +719,7 @@ impl IosVoiceUnit {
// scratch are owned by the closure — no Mutex needed
// because the input callback is the sole writer/reader on
// the audio thread.
let wav_recorder = Arc::new(Mutex::new({
let cfg = params.audio_processing_config.lock().unwrap().clone();
if cfg.debug_wav_dump_enabled {
Some(crate::debug_wav::WavDebugRecorder::start(
cfg.route,
cfg.processing_backend,
))
} else {
None
}
}));
let mut capture_state = IosCaptureState::new(&params, wav_recorder.clone())?;
let mut capture_state = IosCaptureState::new(&params)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
@@ -879,11 +839,8 @@ impl IosVoiceUnit {
tokio::spawn(async move {
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
let mut interval =
tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(
tokio::time::MissedTickBehavior::Delay,
);
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
if producer_shutdown_for_task.load(Ordering::Relaxed) {
@@ -909,16 +866,16 @@ impl IosVoiceUnit {
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data,
num_frames,
..
data, num_frames, ..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
let needed = num_frames * out_channels;
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
for sample in &mut out[..needed] { *sample = 0; }
for sample in &mut out[..needed] {
*sample = 0;
}
return Ok(());
}
@@ -939,10 +896,8 @@ impl IosVoiceUnit {
let mono = (l_lim + r_lim) * 0.5;
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
} else {
out[base] =
(l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
out[base + 1] =
(r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
}
written_frames += 1;
}
@@ -951,17 +906,18 @@ impl IosVoiceUnit {
let remaining = num_frames - written_frames;
for f in 0..remaining {
let base = (written_frames + f) * out_channels;
for c in 0..out_channels { out[base + c] = 0; }
for c in 0..out_channels {
out[base + c] = 0;
}
}
}
let gain = f32::from_bits(
output_gain_for_render.load(Ordering::Relaxed),
);
let muted =
output_muted_for_render.load(Ordering::Relaxed);
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
let muted = output_muted_for_render.load(Ordering::Relaxed);
if muted {
for sample in &mut out[..needed] { *sample = 0; }
for sample in &mut out[..needed] {
*sample = 0;
}
} else if gain != 1.0 {
for sample in &mut out[..needed] {
*sample = (((*sample as f32) * gain)
@@ -972,9 +928,7 @@ impl IosVoiceUnit {
Ok(())
})
.map_err(|e| AudioError::Backend(format!(
"audio unit set render callback: {e}"
)))?;
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
}
// iOS path: direct fill_buffer in callback. iOS VPIO
@@ -984,7 +938,7 @@ impl IosVoiceUnit {
// producer-task path above.
#[cfg(target_os = "ios")]
{
let mut scratch_stereo: Vec<f32> = vec![0.0; 4096 * 2];
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
let handler_for_render = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
@@ -992,19 +946,27 @@ impl IosVoiceUnit {
// Level meter decimation: the render callback fires ~93
// times/sec, but the bridge consumer reads at ~30 Hz.
let mut render_level_decimation: u32 = 0;
// Debug WAV render-reference capture is intentionally unavailable
// on iOS VPIO callbacks until WavDebugRecorder supports a
// preallocated handoff; its current push path allocates per frame.
// Diagnostic counters sampled every 100 callbacks.
let mut cb_count: u64 = 0;
let mut last_num_frames: usize = 0;
let mut num_frames_changes: u64 = 0;
let mut callbacks_with_audio: u64 = 0;
let mut callbacks_with_silence: u64 = 0;
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let render_callback::Args {
data,
num_frames,
..
data, num_frames, ..
} = args;
let out: &mut [i16] = data.buffer;
let out_channels = data.channels;
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
let needed = num_frames * 2;
if scratch_stereo.len() < needed {
scratch_stereo.resize(needed, 0.0);
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
if process_frames < num_frames {
audio_processing_stats_for_render.increment_callback_xrun();
}
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
let needed = process_frames * 2;
// Zero the live slice. AudioHandler::fill_buffer is
// additive (does NOT clear); residual values from
// earlier callbacks (when scratch was bigger) would
@@ -1036,7 +998,8 @@ impl IosVoiceUnit {
muted,
);
if mix_stats.clipped_samples > 0 {
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
audio_processing_stats_for_render
.add_clipped_samples(mix_stats.clipped_samples);
}
render_level_decimation = render_level_decimation.wrapping_add(1);
if render_level_decimation % 3 == 0 {
@@ -1046,30 +1009,6 @@ impl IosVoiceUnit {
);
}
if let Ok(guard) = wav_recorder_for_render.try_lock() {
if let Some(rec) = guard.as_ref() {
if !render_recorder_active {
render_ref_len = 0;
render_ref_accum.fill(0.0);
render_recorder_active = true;
}
let mut idx = 0;
while idx + 1 < needed {
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
render_ref_accum[render_ref_len] = mono;
render_ref_len += 1;
idx += 2;
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
rec.push_render_reference(&render_ref_accum);
render_ref_len = 0;
}
}
} else {
render_recorder_active = false;
}
} else {
render_recorder_active = false;
}
// Track audio-vs-silence for the diagnostic.
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
+13 -4
View File
@@ -28,10 +28,17 @@
#![warn(missing_docs)]
#[cfg(any(target_os = "android", test))]
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod android_render_ring;
#[cfg(any(target_os = "android", test))]
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod audio_event_queue;
pub mod audio_processing;
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod capture_accumulator;
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
mod capture_resampler;
pub mod debug_wav;
mod engine;
pub mod frame;
@@ -42,6 +49,11 @@ pub mod processor;
pub mod ptt;
pub mod ptt_backends;
pub mod release_tail;
#[cfg_attr(
not(any(target_os = "android", target_os = "ios", test)),
allow(dead_code)
)]
pub(crate) mod render_reference;
pub mod route_policy;
pub mod transmit_mode;
pub mod transmit_selector;
@@ -53,10 +65,7 @@ pub(crate) mod voice_render;
mod sdl_output;
#[cfg(any(target_os = "ios", target_os = "macos"))]
mod ios_voice_unit;
#[cfg(target_os = "ios")]
pub mod ios_raw_unit;
mod ios_voice_unit;
#[cfg(target_os = "android")]
pub mod android_voice_unit;
+198 -15
View File
@@ -3,15 +3,18 @@ use audiopus::{
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
SampleRate as OpusSampleRate,
};
use std::sync::atomic::{AtomicU32, Ordering};
use crossbeam::queue::ArrayQueue;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{info, warn};
use tracing::{debug, info, warn};
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
use crate::AudioError;
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
const VOIP_BITRATE_BPS: i32 = 32_000;
const VOIP_COMPLEXITY: u8 = 10;
@@ -54,10 +57,129 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
);
}
pub(crate) struct EncodedVoiceFrame {
data: [u8; MAX_OPUS_FRAME],
len: usize,
}
pub(crate) struct EncodedVoiceFrameSender {
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
open: Arc<AtomicBool>,
}
enum EncodedVoiceFrameSendError {
Full,
Closed,
}
impl EncodedVoiceFrameSender {
fn new(capacity: usize) -> Self {
Self {
queue: Arc::new(ArrayQueue::new(capacity)),
open: Arc::new(AtomicBool::new(true)),
}
}
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
Arc::clone(&self.queue)
}
fn worker_open_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.open)
}
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
if !self.open.load(Ordering::Relaxed) {
return Err(EncodedVoiceFrameSendError::Closed);
}
self.queue
.push(frame)
.map_err(|_| EncodedVoiceFrameSendError::Full)
}
}
pub(crate) fn start_out_packet_worker(
voice_out_tx: mpsc::Sender<OutPacket>,
frames_sent: Arc<AtomicU32>,
context: &'static str,
) -> Result<EncodedVoiceFrameSender, AudioError> {
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
std::thread::Builder::new()
.name(name)
.spawn(worker)
.map(|_| ())
})
}
fn start_out_packet_worker_with_spawner<S>(
voice_out_tx: mpsc::Sender<OutPacket>,
frames_sent: Arc<AtomicU32>,
context: &'static str,
spawn: S,
) -> Result<EncodedVoiceFrameSender, AudioError>
where
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
{
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
let rx = tx.worker_queue();
let worker_open = tx.worker_open_flag();
spawn(
format!("chanora-{context}-voice-packets"),
Box::new(move || {
loop {
let Some(frame) = rx.pop() else {
if Arc::strong_count(&rx) == 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
continue;
};
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: frame.as_slice(),
});
match voice_out_tx.try_send(packet) {
Ok(()) => {
frames_sent.fetch_add(1, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
worker_open.store(false, Ordering::Relaxed);
break;
}
}
}
}),
)
.map_err(|e| {
tx.open.store(false, Ordering::Relaxed);
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
})?;
Ok(tx)
}
impl EncodedVoiceFrame {
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
if len > opus_out.len() || len > MAX_OPUS_FRAME {
return None;
}
let mut data = [0u8; MAX_OPUS_FRAME];
data[..len].copy_from_slice(&opus_out[..len]);
Some(Self { data, len })
}
fn as_slice(&self) -> &[u8] {
&self.data[..self.len]
}
}
/// Encode-scope send helper for a freshly encoded Opus voice frame.
pub(crate) fn send_voip_frame<F, G>(
voice_out_tx: &mpsc::Sender<OutPacket>,
frames_sent: &AtomicU32,
voice_out_tx: &EncodedVoiceFrameSender,
opus_out: &[u8],
len: usize,
on_full: F,
@@ -66,16 +188,77 @@ pub(crate) fn send_voip_frame<F, G>(
F: FnOnce(),
G: FnOnce(),
{
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: &opus_out[..len],
});
match voice_out_tx.try_send(packet) {
Ok(()) => {
frames_sent.fetch_add(1, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
on_full();
return;
};
match voice_out_tx.push(frame) {
Ok(()) => {}
Err(EncodedVoiceFrameSendError::Full) => on_full(),
Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoded_voice_frame_copies_into_fixed_storage() {
let source = [7u8; MAX_OPUS_FRAME];
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
}
#[test]
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
let source = [0u8; MAX_OPUS_FRAME];
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
}
#[test]
fn encoded_voice_frame_sender_reports_full_without_blocking() {
let sender = EncodedVoiceFrameSender::new(1);
let source = [3u8; MAX_OPUS_FRAME];
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
assert!(sender.push(first).is_ok());
assert!(sender.push(second).is_err());
}
#[test]
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
let sender = EncodedVoiceFrameSender::new(1);
sender.open.store(false, Ordering::Relaxed);
let source = [3u8; MAX_OPUS_FRAME];
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
assert!(matches!(
sender.push(frame),
Err(EncodedVoiceFrameSendError::Closed)
));
assert_eq!(sender.queue.len(), 0);
}
#[test]
fn encoded_voice_frame_sender_reports_spawn_failure() {
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
let frames_sent = Arc::new(AtomicU32::new(0));
let result = start_out_packet_worker_with_spawner(
voice_out_tx,
frames_sent,
"test",
|_name, _worker| Err(std::io::Error::other("spawn failed")),
);
assert!(
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
);
}
}
+28 -11
View File
@@ -22,6 +22,7 @@
use core::fmt;
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
use thiserror::Error;
mod focused;
@@ -108,35 +109,51 @@ impl fmt::Display for PttInputClass {
}
/// Errors raised by a desktop PTT backend.
#[derive(Debug)]
#[derive(Debug, Error)]
pub enum PttBackendError {
/// The OS rejected the backend initialisation (e.g. Raw Input
/// registration failed, event tap creation failed).
#[error("init failed: {0}")]
Init(String),
/// The user-granted permission required for global capture is
/// not granted (typically macOS Input Monitoring / Accessibility).
#[error("permission denied")]
PermissionDenied,
/// The display server or compositor does not expose the
/// expected interface (typically a non-tested Linux compositor).
#[error("unsupported environment")]
UnsupportedEnvironment,
/// Caller submitted a binding whose `platform_key` cannot be
/// parsed in the active OS.
#[error("invalid binding: {0}")]
InvalidBinding(String),
}
impl fmt::Display for PttBackendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Init(s) => write!(f, "init failed: {s}"),
Self::PermissionDenied => f.write_str("permission denied"),
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ptt_backend_error_display_strings_stay_stable() {
assert_eq!(
PttBackendError::Init("rawinput".into()).to_string(),
"init failed: rawinput"
);
assert_eq!(
PttBackendError::PermissionDenied.to_string(),
"permission denied"
);
assert_eq!(
PttBackendError::UnsupportedEnvironment.to_string(),
"unsupported environment"
);
assert_eq!(
PttBackendError::InvalidBinding("bad key".into()).to_string(),
"invalid binding: bad key"
);
}
}
impl std::error::Error for PttBackendError {}
/// Cross-platform desktop PTT backend (SDD-081).
///
/// All implementations call exactly the audio transmit gate's
@@ -26,7 +26,7 @@ use std::thread;
use tracing::{info, warn};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::Foundation::{HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Input::{
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
@@ -35,7 +35,7 @@ use windows::Win32::UI::Input::{
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
HC_ACTION, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
};
@@ -423,7 +423,7 @@ unsafe fn run_raw_input_loop(
// class.
let _atom = RegisterClassExW(&wc);
let hwnd = unsafe {
let hwnd = match unsafe {
CreateWindowExW(
WINDOW_EX_STYLE(0),
class_name,
@@ -433,13 +433,23 @@ unsafe fn run_raw_input_loop(
0,
0,
0,
HWND(HWND_MESSAGE_PTR),
Some(HWND(HWND_MESSAGE_PTR as *mut core::ffi::c_void)),
None,
h_instance,
Some(HINSTANCE(h_instance.0)),
None,
)
} {
Ok(h) => h,
Err(_) => {
warn!(
target: "chanora_audio",
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
);
report!(false);
return false;
}
};
if hwnd.0 == 0 {
if hwnd.0.is_null() {
warn!(
target: "chanora_audio",
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
@@ -517,13 +527,13 @@ unsafe fn run_raw_input_loop(
usUsagePage: 0x01,
usUsage: 0x06,
dwFlags: RIDEV_REMOVE,
hwndTarget: HWND(0),
hwndTarget: HWND(std::ptr::null_mut()),
},
RAWINPUTDEVICE {
usUsagePage: 0x01,
usUsage: 0x02,
dwFlags: RIDEV_REMOVE,
hwndTarget: HWND(0),
hwndTarget: HWND(std::ptr::null_mut()),
},
];
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
@@ -544,7 +554,7 @@ unsafe extern "system" fn raw_input_wnd_proc(
}
unsafe fn handle_wm_input(lparam: LPARAM) {
let h_raw = HRAWINPUT(lparam.0);
let h_raw = HRAWINPUT(lparam.0 as *mut core::ffi::c_void);
let mut size: u32 = 0;
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
// First call: query buffer size.
@@ -841,7 +851,8 @@ unsafe fn run_hook_loop(
let kbd_proc: HOOKPROC = Some(kbd_hook_proc);
let mouse_proc: HOOKPROC = Some(mouse_hook_proc);
let kbd_hook = match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, h_instance, 0) {
let kbd_hook =
match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, Some(HINSTANCE(h_instance.0)), 0) {
Ok(h) => h,
Err(e) => {
warn!(
@@ -853,7 +864,8 @@ unsafe fn run_hook_loop(
return false;
}
};
let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, h_instance, 0) {
let mouse_hook =
match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, Some(HINSTANCE(h_instance.0)), 0) {
Ok(h) => h,
Err(e) => {
warn!(
@@ -908,7 +920,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
}
});
}
CallNextHookEx(HHOOK(0), code, wparam, lparam)
CallNextHookEx(None, code, wparam, lparam)
}
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
@@ -943,7 +955,7 @@ unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPA
}
});
}
CallNextHookEx(HHOOK(0), code, wparam, lparam)
CallNextHookEx(None, code, wparam, lparam)
}
/// Pure-logic dispatcher for a low-level mouse hook event (L0
@@ -0,0 +1,241 @@
use std::array;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
const NO_LATEST_SLOT: usize = usize::MAX;
struct Slot<const SAMPLES: usize> {
version: AtomicUsize,
samples: [AtomicU32; SAMPLES],
#[cfg(test)]
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
}
impl<const SAMPLES: usize> Slot<SAMPLES> {
fn new() -> Self {
Self {
version: AtomicUsize::new(0),
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
#[cfg(test)]
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
}
}
}
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
pending: [f32; SAMPLES],
pending_len: usize,
}
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
pub(crate) fn new() -> Self {
assert!(
SAMPLES > 0,
"RenderReferenceFrameAccumulator requires at least one sample"
);
Self {
pending: [0.0; SAMPLES],
pending_len: 0,
}
}
pub(crate) fn push_mono_samples(
&mut self,
mut samples: &[f32],
mut publish: impl FnMut(&[f32; SAMPLES]),
) {
while !samples.is_empty() {
let needed = SAMPLES - self.pending_len;
let take = needed.min(samples.len());
self.pending[self.pending_len..self.pending_len + take]
.copy_from_slice(&samples[..take]);
self.pending_len += take;
samples = &samples[take..];
if self.pending_len == SAMPLES {
publish(&self.pending);
self.pending_len = 0;
}
}
}
#[cfg(test)]
fn pending_len(&self) -> usize {
self.pending_len
}
}
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
slots: Box<[Slot<SAMPLES>; SLOTS]>,
write_idx: AtomicUsize,
latest_slot: AtomicUsize,
}
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
pub(crate) fn new() -> Arc<Self> {
assert!(
SLOTS > 0,
"RenderReferenceBuffer requires at least one slot"
);
Arc::new(Self {
slots: Box::new(array::from_fn(|_| Slot::new())),
write_idx: AtomicUsize::new(0),
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
})
}
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
let slot = &self.slots[idx];
// The acquire half keeps payload stores after the odd in-progress marker.
let version = slot.version.fetch_add(1, Ordering::AcqRel);
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
sample.store(value.to_bits(), Ordering::Relaxed);
}
slot.version
.store(version.wrapping_add(2) & !1, Ordering::Release);
self.latest_slot.store(idx, Ordering::Release);
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
}
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
let mut out = [0.0_f32; SAMPLES];
self.read_latest_into(&mut out);
out
}
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
let idx = self.latest_slot.load(Ordering::Acquire);
if idx == NO_LATEST_SLOT {
out.fill(0.0);
return;
}
let slot = &self.slots[idx];
let before = slot.version.load(Ordering::Acquire);
if before & 1 == 1 {
out.fill(0.0);
return;
}
#[cfg(not(test))]
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
}
#[cfg(test)]
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
if idx == 0
&& slot
.bump_after_first_sample_read
.swap(false, Ordering::Relaxed)
{
slot.version.fetch_add(2, Ordering::Release);
}
}
let after = slot.version.load(Ordering::Acquire);
if before != after || after & 1 == 1 {
out.fill(0.0);
}
}
#[cfg(test)]
fn mark_latest_slot_in_progress_for_test(&self) {
let idx = self.latest_slot.load(Ordering::Acquire);
assert_ne!(idx, NO_LATEST_SLOT);
self.slots[idx].version.fetch_or(1, Ordering::Release);
}
#[cfg(test)]
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
let idx = self.latest_slot.load(Ordering::Acquire);
assert_ne!(idx, NO_LATEST_SLOT);
self.slots[idx]
.bump_after_first_sample_read
.store(true, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
#[test]
fn render_reference_reads_zero_before_first_publish() {
let buffer = RenderReferenceBuffer::<4, 2>::new();
assert_eq!(buffer.read_latest(), [0.0; 4]);
}
#[test]
fn render_reference_reader_gets_latest_complete_frame() {
let buffer = RenderReferenceBuffer::<4, 3>::new();
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
}
#[test]
fn render_reference_writes_wrap_without_returning_stale_frame() {
let buffer = RenderReferenceBuffer::<2, 2>::new();
buffer.write(&[1.0, 2.0]);
buffer.write(&[3.0, 4.0]);
buffer.write(&[5.0, 6.0]);
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
}
#[test]
fn render_reference_accumulator_publishes_only_complete_frames() {
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
let mut frames = Vec::new();
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
assert!(frames.is_empty());
assert_eq!(accum.pending_len(), 2);
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
assert_eq!(accum.pending_len(), 3);
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
assert_eq!(accum.pending_len(), 0);
}
#[test]
fn render_reference_reader_rejects_in_progress_slot() {
let buffer = RenderReferenceBuffer::<2, 1>::new();
buffer.write(&[1.0, 2.0]);
buffer.mark_latest_slot_in_progress_for_test();
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
}
#[test]
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
let buffer = RenderReferenceBuffer::<2, 1>::new();
buffer.write(&[1.0, 2.0]);
buffer.bump_latest_slot_version_after_first_sample_for_test();
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
}
#[test]
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
fn render_reference_rejects_zero_slots() {
let _ = RenderReferenceBuffer::<2, 0>::new();
}
}
+26 -2
View File
@@ -8,7 +8,7 @@
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub mod apple_coreml;
pub mod resampler;
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub mod silero_onnx;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -18,7 +18,7 @@ use crate::frame::{f32_to_i16, i16_to_f32};
use crate::AudioError;
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub use silero_onnx::SileroOnnxVad;
/// Voice activity detector output for one 10 ms frame.
@@ -111,6 +111,9 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
pub(crate) static SILERO_MODEL_PATH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
}
@@ -145,6 +148,19 @@ pub fn silero_model_epoch() -> u64 {
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
}
#[cfg(test)]
pub(crate) fn clear_silero_model_path_for_test() {
set_silero_model_path_for_test(None);
}
#[cfg(test)]
pub(crate) fn set_silero_model_path_for_test(path: Option<String>) {
if let Ok(mut guard) = silero_model_path_override().write() {
*guard = path;
SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
}
}
/// Return the expected path of the Silero VAD v6 ONNX model on
/// supported platforms.
/// The model is shipped as a Flutter asset and copied to the app's
@@ -234,13 +250,20 @@ mod tests {
#[test]
fn set_silero_model_path_rejects_missing_file() {
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
clear_silero_model_path_for_test();
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
assert!(result.is_err());
clear_silero_model_path_for_test();
}
#[test]
fn set_silero_model_path_updates_override_and_epoch() {
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
clear_silero_model_path_for_test();
let path =
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
std::fs::write(&path, b"test").unwrap();
@@ -250,6 +273,7 @@ mod tests {
assert!(silero_model_epoch() > before);
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
clear_silero_model_path_for_test();
let _ = std::fs::remove_file(path);
}
}
+33 -1
View File
@@ -361,6 +361,31 @@ impl SileroOnnxVadWorker {
})
}
#[cfg(test)]
pub(crate) fn stale_test_worker() -> Self {
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(64);
let alive = Arc::new(AtomicBool::new(true));
let alive_for_thread = alive.clone();
let handle = std::thread::Builder::new()
.name("chanora-silero-vad-stale-test".to_string())
.spawn(move || {
while alive_for_thread.load(Ordering::Relaxed) {
match rx.recv_timeout(std::time::Duration::from_millis(10)) {
Ok(_) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
})
.ok();
Self {
tx: Some(tx),
latest_probability: Arc::new(AtomicU32::new(0.0_f32.to_bits())),
latest_processed_seq: Arc::new(AtomicU64::new(u64::MAX)),
alive,
handle,
}
}
/// Best-effort enqueue of a 10 ms frame for background inference.
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
let Some(tx) = &self.tx else {
@@ -393,8 +418,15 @@ impl SileroOnnxVadWorker {
impl Drop for SileroOnnxVadWorker {
fn drop(&mut self) {
self.alive.store(false, Ordering::Relaxed);
// Drop the sender first so the worker thread's rx.recv() returns
// Err and the loop exits promptly.
let _ = self.tx.take();
let _ = self.handle.take();
// Join the thread instead of detaching. The channel close
// unblocks rx.recv() so the join is bounded; it waits at most
// until the current in-flight inference completes.
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
+5 -47
View File
@@ -1,4 +1,5 @@
/// Diagnostics returned by render downmix helpers.
#[cfg(any(target_os = "ios", test))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct RenderDownmixStats {
/// Peak absolute sample magnitude after i16 conversion.
@@ -7,47 +8,7 @@ pub(crate) struct RenderDownmixStats {
pub clipped_samples: u64,
}
/// Downmix interleaved stereo f32 samples into mono i16 samples.
///
/// The helper is allocation-free and safe for realtime render callbacks.
/// If the stereo source is shorter than expected, the remainder of `out`
/// is filled with silence.
#[cfg(any(target_os = "ios", test))]
pub(crate) fn downmix_stereo_f32_to_mono_i16(
stereo: &[f32],
out: &mut [i16],
gain: f32,
muted: bool,
) -> RenderDownmixStats {
if muted {
out.fill(0);
return RenderDownmixStats::default();
}
let available_frames = stereo.len() / 2;
if available_frames < out.len() {
out.fill(0);
}
let mut peak = 0_u16;
let mut clipped_samples = 0_u64;
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
let mono = (lr[0] + lr[1]) * 0.5 * gain;
let clamped = mono.clamp(-1.0, 1.0);
if (mono - clamped).abs() > f32::EPSILON {
clipped_samples = clipped_samples.saturating_add(1);
}
let sample = (clamped * i16::MAX as f32) as i16;
*dst = sample;
peak = peak.max(sample.unsigned_abs());
}
RenderDownmixStats {
peak_i16: peak.min(i16::MAX as u16) as i16,
clipped_samples,
}
}
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
stereo: &[f32],
out: &mut [i16],
@@ -122,10 +83,7 @@ pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
if threshold <= 0.0 || !threshold.is_finite() {
return 1.0;
}
let peak = samples
.iter()
.map(|s| s.abs())
.fold(0.0_f32, f32::max);
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
if peak <= threshold {
return 1.0;
}
@@ -145,7 +103,7 @@ mod tests {
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
let mut out = [0_i16; 3];
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
assert_eq!(out[0], i16::MAX);
assert_eq!(out[1], 0);
@@ -159,7 +117,7 @@ mod tests {
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
let mut out = [123_i16; 2];
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
assert_eq!(out, [0, 0]);
assert_eq!(stats, RenderDownmixStats::default());
@@ -234,7 +192,7 @@ mod tests {
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
limit_peak_inplace(&mut scratch, 0.95);
let mut out = [0_i16; 3];
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
assert_eq!(stats.clipped_samples, 0);
assert!(stats.peak_i16 < i16::MAX);
}
+2 -2
View File
@@ -196,8 +196,8 @@ fn windows_exercise() {
// start/stop lifecycle through the public trait surface so
// any info!/warn! the factory or the backend's `start` path
// emits is captured by the layer.
use chanora_audio::ptt_backends::{select_ptt_backend, PttBinding, PttInputClass};
use chanora_audio::AudioTransmitGate;
use chanora_audio::ptt_backends::{PttBinding, PttInputClass};
use chanora_audio::{select_ptt_backend, AudioTransmitGate};
let mut backend = select_ptt_backend();
let gate = AudioTransmitGate::new(false);
+146 -41
View File
@@ -1062,10 +1062,8 @@ pub enum BridgeAudioRoute {
/// Bridge iOS voice-processing mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeIosVoiceProcessingMode {
/// Shipping VPIO path.
/// Apple VoiceProcessingIO path.
PlatformVoiceProcessing,
/// Experimental Sonora path.
SonoraExperimental,
}
/// Bridge processing backend.
@@ -1223,7 +1221,6 @@ impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
}
}
}
@@ -1234,7 +1231,6 @@ impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
Self::PlatformVoiceProcessing
}
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
}
}
}
@@ -1469,6 +1465,54 @@ pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
Ok(())
}
/// Configure the bridge blob cache root.
pub async fn init_cache(dir: String) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().init_cache(&dir).await })
.await
.map_err(|e| task_join_error("init_cache", e))??;
Ok(())
}
/// Resolve avatar bytes through the bridge.
pub async fn download_avatar(
avatar_hash: String,
client_uid: String,
) -> Result<Option<Vec<u8>>, BridgeError> {
runtime()
.spawn(async move { session().get_avatar(&avatar_hash, &client_uid).await })
.await
.map_err(|e| task_join_error("download_avatar", e))?
.map_err(BridgeError::from)
}
/// Resolve icon bytes through the bridge.
pub async fn download_icon(icon_id: u64) -> Result<Option<Vec<u8>>, BridgeError> {
runtime()
.spawn(async move { session().get_icon(icon_id).await })
.await
.map_err(|e| task_join_error("download_icon", e))?
.map_err(BridgeError::from)
}
/// Purge cached protocol-owned assets.
pub async fn clear_file_cache() -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().clear_cache().await })
.await
.map_err(|e| task_join_error("clear_file_cache", e))??;
Ok(())
}
/// Report the configured file-cache size.
pub async fn file_cache_size() -> Result<u64, BridgeError> {
runtime()
.spawn(async move { session().cache_size().await })
.await
.map_err(|e| task_join_error("file_cache_size", e))?
.map_err(BridgeError::from)
}
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
#[derive(Debug, Clone)]
pub struct BridgeBookmark {
@@ -1695,6 +1739,8 @@ pub enum BridgeEvent {
message: String,
/// Target scope (server/channel/private/poke).
target: BridgeMessageTarget,
/// Poke notification strength, present only for poke messages.
poke_strength: Option<BridgePokeStrength>,
},
/// Human-readable server activity surfaced from protocol bookkeeping events.
ServerActivity {
@@ -1812,6 +1858,27 @@ impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
}
}
/// Bridge poke notification strength.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgePokeStrength {
/// Poke should be surfaced at full strength.
Strong,
/// Poke is rate-limited but below overflow severity.
Suppressed,
/// Poke remains suppressed after repeated suppressed pokes.
SuppressedOverflow,
}
impl From<chanora_core::PokeStrength> for BridgePokeStrength {
fn from(strength: chanora_core::PokeStrength) -> Self {
match strength {
chanora_core::PokeStrength::Strong => Self::Strong,
chanora_core::PokeStrength::Suppressed => Self::Suppressed,
chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow,
}
}
}
/// Bridge mirror of core join projection sync state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinSyncState {
@@ -1958,11 +2025,13 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
sender_name,
message,
target,
poke_strength,
} => BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target: target.into(),
poke_strength: poke_strength.map(Into::into),
},
chanora_core::SessionEvent::ServerActivity { message } => {
BridgeEvent::ServerActivity { message }
@@ -1972,27 +2041,77 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
route: route.into(),
}
}
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
BridgeEvent::ClientMoved { client_id, new_channel_id }
}
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
}
chanora_core::SessionEvent::ClientMoved {
client_id,
new_channel_id,
} => BridgeEvent::ClientMoved {
client_id,
new_channel_id,
},
chanora_core::SessionEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => BridgeEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
},
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
BridgeEvent::ClientLeft { client_id, name }
}
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
}
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
}
chanora_core::SessionEvent::ChannelRemoved { id } => {
BridgeEvent::ChannelRemoved { id }
}
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
}
chanora_core::SessionEvent::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => BridgeEvent::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
},
chanora_core::SessionEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} => BridgeEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
},
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
chanora_core::SessionEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} => BridgeEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
},
}
}
}
@@ -2286,25 +2405,11 @@ pub async fn set_ios_voice_processing_mode(
let config = BridgeAudioProcessingConfig {
route: BridgeAudioRoute::Speaker,
ios_mode: mode,
processing_backend: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
BridgeAudioBackend::PlatformVoiceProcessing
}
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
},
processing_backend: BridgeAudioBackend::PlatformVoiceProcessing,
vad_backend: BridgeVadBackend::SileroOnnx,
aec: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
},
ns: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
},
agc: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
},
aec: BridgeEffectOwner::Platform,
ns: BridgeEffectOwner::Platform,
agc: BridgeEffectOwner::Platform,
hpf_enabled: true,
limiter_enabled: true,
vad_hangover_ms: 500,
+333 -50
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 635684021;
// Section: executor
@@ -186,6 +186,41 @@ fn wire__crate__api__bridge_init_impl(
},
)
}
fn wire__crate__api__clear_file_cache_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "clear_file_cache",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::clear_file_cache().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__client_profile_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -332,6 +367,80 @@ fn wire__crate__api__disconnect_impl(
},
)
}
fn wire__crate__api__download_avatar_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "download_avatar",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_avatar_hash = <String>::sse_decode(&mut deserializer);
let api_client_uid = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok =
crate::api::download_avatar(api_avatar_hash, api_client_uid).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__download_icon_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "download_icon",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_icon_id = <u64>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::download_icon(api_icon_id).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__enable_audio_debug_wav_dump_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -434,6 +543,41 @@ fn wire__crate__api__export_diagnostics_impl(
},
)
}
fn wire__crate__api__file_cache_size_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "file_cache_size",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::file_cache_size().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__get_audio_processing_config_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -702,6 +846,42 @@ fn wire__crate__api__handle_route_change_impl(
},
)
}
fn wire__crate__api__init_cache_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "init_cache",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_dir = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::init_cache(api_dir).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__init_storage_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -2292,11 +2472,14 @@ impl SseDecode for crate::api::BridgeEvent {
let mut var_senderName = <String>::sse_decode(deserializer);
let mut var_message = <String>::sse_decode(deserializer);
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
let mut var_pokeStrength =
<Option<crate::api::BridgePokeStrength>>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChatMessage {
sender_id: var_senderId,
sender_name: var_senderName,
message: var_message,
target: var_target,
poke_strength: var_pokeStrength,
};
}
11 => {
@@ -2406,7 +2589,6 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing,
1 => crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental,
_ => unreachable!(
"Invalid variant for BridgeIosVoiceProcessingMode: {}",
inner
@@ -2454,6 +2636,19 @@ impl SseDecode for crate::api::BridgeNetworkState {
}
}
impl SseDecode for crate::api::BridgePokeStrength {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::api::BridgePokeStrength::Strong,
1 => crate::api::BridgePokeStrength::Suppressed,
2 => crate::api::BridgePokeStrength::SuppressedOverflow,
_ => unreachable!("Invalid variant for BridgePokeStrength: {}", inner),
};
}
}
impl SseDecode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2680,6 +2875,17 @@ impl SseDecode for Option<String> {
}
}
impl SseDecode for Option<crate::api::BridgePokeStrength> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<crate::api::BridgePokeStrength>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2737,6 +2943,17 @@ impl SseDecode for Option<u64> {
}
}
impl SseDecode for Option<Vec<u8>> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<Vec<u8>>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for crate::api::PermissionStateKind {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2790,45 +3007,50 @@ fn pde_ffi_dispatcher_primary_impl(
2 => wire__crate__api__audio_processing_stats_impl(port, ptr, rust_vec_len, data_len),
3 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
37 => {
5 => wire__crate__api__clear_file_cache_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__download_avatar_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__download_icon_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__file_cache_size_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__init_cache_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
42 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
51 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
52 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
54 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
55 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2841,19 +3063,19 @@ fn pde_ffi_dispatcher_sync_impl(
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
11 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
14 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
20 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
21 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
22 => wire__crate__api__handle_media_services_reset_with_route_impl(
ptr,
rust_vec_len,
data_len,
),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
34 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -3299,12 +3521,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
sender_name,
message,
target,
poke_strength,
} => [
10.into_dart(),
sender_id.into_into_dart().into_dart(),
sender_name.into_into_dart().into_dart(),
message.into_into_dart().into_dart(),
target.into_into_dart().into_dart(),
poke_strength.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ServerActivity { message } => {
@@ -3416,7 +3640,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeIosVoiceProcessingMode
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::PlatformVoiceProcessing => 0.into_dart(),
Self::SonoraExperimental => 1.into_dart(),
_ => unreachable!(),
}
}
@@ -3484,6 +3707,28 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePokeStrength {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::Strong => 0.into_dart(),
Self::Suppressed => 1.into_dart(),
Self::SuppressedOverflow => 2.into_dart(),
_ => unreachable!(),
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgePokeStrength
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePokeStrength>
for crate::api::BridgePokeStrength
{
fn into_into_dart(self) -> crate::api::BridgePokeStrength {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@@ -4053,12 +4298,14 @@ impl SseEncode for crate::api::BridgeEvent {
sender_name,
message,
target,
poke_strength,
} => {
<i32>::sse_encode(10, serializer);
<u64>::sse_encode(sender_id, serializer);
<String>::sse_encode(sender_name, serializer);
<String>::sse_encode(message, serializer);
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
<Option<crate::api::BridgePokeStrength>>::sse_encode(poke_strength, serializer);
}
crate::api::BridgeEvent::ServerActivity { message } => {
<i32>::sse_encode(11, serializer);
@@ -4162,7 +4409,6 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
<i32>::sse_encode(
match self {
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1,
_ => {
unimplemented!("");
}
@@ -4214,6 +4460,23 @@ impl SseEncode for crate::api::BridgeNetworkState {
}
}
impl SseEncode for crate::api::BridgePokeStrength {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(
match self {
crate::api::BridgePokeStrength::Strong => 0,
crate::api::BridgePokeStrength::Suppressed => 1,
crate::api::BridgePokeStrength::SuppressedOverflow => 2,
_ => {
unimplemented!("");
}
},
serializer,
);
}
}
impl SseEncode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -4429,6 +4692,16 @@ impl SseEncode for Option<String> {
}
}
impl SseEncode for Option<crate::api::BridgePokeStrength> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<crate::api::BridgePokeStrength>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -4479,6 +4752,16 @@ impl SseEncode for Option<u64> {
}
}
impl SseEncode for Option<Vec<u8>> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<Vec<u8>>::sse_encode(value, serializer);
}
}
}
impl SseEncode for crate::api::PermissionStateKind {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+4
View File
@@ -110,9 +110,13 @@ impl From<chanora_core::CoreError> for BridgeError {
code,
message,
}) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::FileTransfer(p)) => {
BridgeError::Connection(format!("file transfer: {p}"))
}
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
chanora_core::CoreError::Cache(c) => BridgeError::Connection(format!("cache: {c}")),
other => BridgeError::Unmapped(format!("{other}")),
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "chanora_cache"
description = "Chanora disposable content-addressed blob cache for avatars and icons"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[dependencies]
cacache = "13"
thiserror.workspace = true
tokio = { version = "1", features = ["fs", "rt"] }
tracing.workspace = true
[dev-dependencies]
tempfile = "3"
tokio = { version = "1", features = ["rt", "macros", "time"] }
+359
View File
@@ -0,0 +1,359 @@
//! Disposable content-addressed blob cache for avatar and icon files.
//!
//! Wraps [`cacache`] for production-tested crash safety and integrity
//! verification. The on-disk layout is managed by cacache (content-v2,
//! index-v2). Chanora maps protocol keys (`av_<md5>`, `ic_<crc32>`) to
//! cacache string keys.
//!
//! This crate is intentionally separate from `chanora_storage`:
//! storage owns persistent identity/bookmark data; cache owns
//! reconstructible, disposable blob data with different durability
//! and backup semantics.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::path::{Path, PathBuf};
/// Errors raised by the blob cache.
#[derive(Debug, thiserror::Error)]
pub enum BlobCacheError {
/// Filesystem I/O error.
#[error("io: {0}")]
Io(String),
/// Key validation error.
#[error("invalid key: {0}")]
InvalidKey(String),
}
/// Content-addressed blob cache backed by cacache.
pub struct BlobCache {
cache_dir: PathBuf,
/// Maximum total cache size in bytes. 0 = no limit.
max_bytes: u64,
}
/// Avatar blob prefix.
pub const PREFIX_AVATAR: &str = "av_";
/// Icon blob prefix.
pub const PREFIX_ICON: &str = "ic_";
impl BlobCache {
/// Create or open a [`BlobCache`] rooted at `cache_dir/chanora/`.
///
/// Creates the cacache directory. `max_bytes` sets the eviction
/// threshold; 0 means no automatic eviction.
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError> {
let cache_dir = cache_dir.as_ref().join("chanora").join("blobs");
// cacache creates the directory on first write, but we create
// it eagerly so total_size() works before any writes.
std::fs::create_dir_all(&cache_dir)
.map_err(|e| BlobCacheError::Io(format!("mkdir cache: {e}")))?;
Ok(Self {
cache_dir,
max_bytes,
})
}
/// Store a blob. `prefix` is [`PREFIX_AVATAR`] or [`PREFIX_ICON`].
/// `key` is the content hash (MD5 hex for avatars, unsigned
/// decimal CRC32 for icons).
///
/// Cacache handles dedup and integrity internally.
pub async fn put(
&self,
prefix: &str,
key: &str,
data: &[u8],
) -> Result<(), BlobCacheError> {
validate_key(prefix, key)?;
let cache_key = format!("{prefix}{key}");
cacache::write(&self.cache_dir, &cache_key, data)
.await
.map_err(|e| BlobCacheError::Io(format!("cacache write: {e}")))?;
Ok(())
}
/// Read a blob. Returns `None` if not cached.
///
/// Cacache verifies SSRI integrity on every read.
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError> {
validate_key(prefix, key)?;
let cache_key = format!("{prefix}{key}");
match cacache::read(&self.cache_dir, &cache_key).await {
Ok(data) => Ok(Some(data)),
Err(cacache::Error::EntryNotFound(_, _)) => Ok(None),
Err(e) => {
// Integrity failure or I/O error — remove corrupt entry.
tracing::warn!(
target: "chanora_cache",
key = %cache_key,
error = %e,
"cache read failed; removing entry"
);
let _ = cacache::remove(&self.cache_dir, &cache_key).await;
Ok(None)
}
}
}
/// Delete a specific blob.
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError> {
validate_key(prefix, key)?;
let cache_key = format!("{prefix}{key}");
cacache::remove(&self.cache_dir, &cache_key)
.await
.map_err(|e| BlobCacheError::Io(format!("cacache remove: {e}")))?;
Ok(())
}
/// Delete all blobs.
pub async fn clear(&self) -> Result<(), BlobCacheError> {
let path = self.cache_dir.clone();
tokio::task::spawn_blocking(move || {
if path.exists() {
std::fs::remove_dir_all(&path)
.map_err(|e| BlobCacheError::Io(format!("clear cache: {e}")))?;
std::fs::create_dir_all(&path)
.map_err(|e| BlobCacheError::Io(format!("recreate cache dir: {e}")))?;
}
Ok(())
})
.await
.map_err(|e| BlobCacheError::Io(format!("clear task: {e}")))?
}
/// Return total bytes used by all blobs.
///
/// Walks cacache entries and sums sizes.
pub async fn total_size(&self) -> Result<u64, BlobCacheError> {
let cache_dir = self.cache_dir.clone();
tokio::task::spawn_blocking(move || {
let mut total: u64 = 0;
for entry in cacache::list_sync(&cache_dir) {
match entry {
Ok(meta) => total += meta.size as u64,
Err(e) => {
tracing::warn!(
target: "chanora_cache",
error = %e,
"skipping bad entry during size scan"
);
}
}
}
Ok(total)
})
.await
.map_err(|e| BlobCacheError::Io(format!("total_size task: {e}")))?
}
/// Evict oldest entries by timestamp until total size is under
/// `max_bytes`. Call on startup or periodically. No-op if
/// `max_bytes` is 0.
pub async fn evict(&self) -> Result<(), BlobCacheError> {
if self.max_bytes == 0 {
return Ok(());
}
let cache_dir = self.cache_dir.clone();
let max_bytes = self.max_bytes;
tokio::task::spawn_blocking(move || {
let mut entries: Vec<(String, usize, u128)> = Vec::new();
for entry in cacache::list_sync(&cache_dir) {
match entry {
Ok(meta) => {
entries.push((meta.key, meta.size, meta.time));
}
Err(e) => {
tracing::warn!(
target: "chanora_cache",
error = %e,
"skipping bad entry during eviction scan"
);
}
}
}
let total: usize = entries.iter().map(|(_, s, _)| *s).sum();
if total as u64 <= max_bytes {
return Ok(());
}
entries.sort_by_key(|(_, _, t)| *t);
let mut freed: usize = 0;
let target = total - max_bytes as usize;
for (key, size, _) in entries {
if freed >= target {
break;
}
let _ = cacache::remove_sync(&cache_dir, &key);
freed += size;
}
tracing::info!(
target: "chanora_cache",
freed_bytes = freed,
"evicted oldest blobs"
);
Ok(())
})
.await
.map_err(|e| BlobCacheError::Io(format!("evict task: {e}")))?
}
}
/// Validate key format to prevent malformed entries.
fn validate_key(prefix: &str, key: &str) -> Result<(), BlobCacheError> {
if !matches!(prefix, PREFIX_AVATAR | PREFIX_ICON) {
return Err(BlobCacheError::InvalidKey(format!("bad prefix: {prefix}")));
}
match prefix {
PREFIX_AVATAR => {
// MD5 hex = exactly 32 hex chars.
if key.len() != 32 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(BlobCacheError::InvalidKey(format!(
"avatar key must be 32 hex chars, got: {key}"
)));
}
}
PREFIX_ICON => {
// Unsigned CRC32 = decimal digits.
if key.is_empty() || !key.chars().all(|c| c.is_ascii_digit()) {
return Err(BlobCacheError::InvalidKey(format!(
"icon key must be decimal digits, got: {key}"
)));
}
}
_ => unreachable!(),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("chanora_cache_test_")
.tempdir()
.unwrap()
}
#[tokio::test]
async fn put_get_roundtrip() {
let tmp = tempdir();
let cache = BlobCache::new(&tmp, 0).unwrap();
assert!(cache
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
.await
.unwrap()
.is_none());
cache
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar-bytes")
.await
.unwrap();
let data = cache
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
.await
.unwrap();
assert_eq!(data.as_deref(), Some(b"avatar-bytes".as_slice()));
}
#[tokio::test]
async fn get_missing_returns_none() {
let tmp = tempdir();
let cache = BlobCache::new(&tmp, 0).unwrap();
assert!(cache
.get(PREFIX_AVATAR, "00000000000000000000000000000000")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn clear_removes_all() {
let tmp = tempdir();
let cache = BlobCache::new(&tmp, 0).unwrap();
cache
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
.await
.unwrap();
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
cache.clear().await.unwrap();
assert_eq!(cache.total_size().await.unwrap(), 0);
}
#[tokio::test]
async fn total_size_accounts_for_all_entries() {
let tmp = tempdir();
let cache = BlobCache::new(&tmp, 0).unwrap();
cache
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"12345")
.await
.unwrap();
cache.put(PREFIX_ICON, "99999", b"12").await.unwrap();
assert_eq!(cache.total_size().await.unwrap(), 5 + 2);
}
#[tokio::test]
async fn invalid_key_rejected() {
let tmp = tempdir();
let cache = BlobCache::new(&tmp, 0).unwrap();
// Too short for MD5.
assert!(cache.put(PREFIX_AVATAR, "abc", b"data").await.is_err());
// Non-hex in MD5.
assert!(cache
.put(PREFIX_AVATAR, "g".repeat(32).as_str(), b"data")
.await
.is_err());
// Non-digit in icon key.
assert!(cache.put(PREFIX_ICON, "12a45", b"data").await.is_err());
// Bad prefix.
assert!(cache.put("xx_", "abc", b"data").await.is_err());
}
#[tokio::test]
async fn evict_deletes_oldest_until_under_cap() {
let tmp = tempdir();
// 10 byte cap.
let cache = BlobCache::new(&tmp, 10).unwrap();
cache
.put(
PREFIX_AVATAR,
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
b"12345678",
)
.await
.unwrap(); // 8 bytes
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
cache
.put(PREFIX_ICON, "11111", b"12345")
.await
.unwrap(); // 5 bytes → total 13, over cap
cache.evict().await.unwrap();
// Oldest (avatar) should be evicted.
assert!(cache
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
.await
.unwrap()
.is_none());
assert!(cache.get(PREFIX_ICON, "11111").await.unwrap().is_some());
}
#[tokio::test]
async fn remove_deletes_entry() {
let tmp = tempdir();
let cache = BlobCache::new(&tmp, 0).unwrap();
cache
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
.await
.unwrap();
cache
.remove(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
.await
.unwrap();
assert!(cache
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
.await
.unwrap()
.is_none());
}
}
+19 -7
View File
@@ -36,7 +36,7 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::HashSet;
use std::collections::{HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use thiserror::Error;
@@ -712,7 +712,7 @@ impl DiagnosticExport {
/// diagnostic export and state-sync replay verification.
#[derive(Debug, Clone)]
pub struct ProtocolEventRecorder {
events: Vec<String>,
events: VecDeque<String>,
capacity: usize,
}
@@ -720,17 +720,20 @@ impl ProtocolEventRecorder {
/// Create a recorder with the given ring-buffer capacity.
pub fn new(capacity: usize) -> Self {
Self {
events: Vec::with_capacity(capacity),
events: VecDeque::with_capacity(capacity),
capacity,
}
}
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
if self.capacity == 0 {
return;
}
let s = format!("[{ts}] {kind}: {detail}");
if self.events.len() >= self.capacity {
self.events.remove(0);
self.events.pop_front();
}
self.events.push(s);
self.events.push_back(s);
}
/// Record a successful connection.
@@ -777,12 +780,12 @@ impl ProtocolEventRecorder {
/// Drain all recorded events and reset the buffer.
pub fn drain(&mut self) -> Vec<String> {
std::mem::take(&mut self.events)
self.events.drain(..).collect()
}
/// Snapshot all recorded events without clearing the buffer.
pub fn snapshot(&self) -> Vec<String> {
self.events.clone()
self.events.iter().cloned().collect()
}
}
@@ -1103,4 +1106,13 @@ mod tests {
assert_eq!(first, second);
assert_eq!(drained, first);
}
#[test]
fn protocol_event_zero_capacity_drops_events() {
let mut recorder = ProtocolEventRecorder::new(0);
recorder.record_connected("Server");
assert!(recorder.snapshot().is_empty());
assert!(recorder.drain().is_empty());
}
}
+306 -30
View File
@@ -24,6 +24,7 @@ use base64::prelude::*;
use chanora_resolver::ChanoraResolver;
use futures::prelude::*;
use std::collections::HashMap;
use tokio::io::AsyncReadExt;
use tokio::sync::{mpsc, oneshot};
use tracing::{info, warn};
@@ -32,7 +33,8 @@ use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
use tsclientlib::prelude::*;
use tsclientlib::{
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
DisconnectOptions, Identity, MessageHandle, OutCommandExt, StreamItem, Version,
DisconnectOptions, FileDownloadResult, FiletransferHandle, Identity, MessageHandle,
OutCommandExt, StreamItem, Version,
};
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
use tsproto_types::ClientType;
@@ -41,11 +43,15 @@ use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ProtocolDelta, ServerActivity, ServerSnapshot,
};
use crate::poke_limiter::PokeLimiter;
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
const OUTBOUND_VOICE_PACKETS_PER_TICK: usize = 8;
const DISCONNECT_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
const DISCONNECT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
type PendingMoves = HashMap<
MessageHandle,
@@ -56,6 +62,9 @@ type PendingMoves = HashMap<
),
>;
type PendingDownloads =
HashMap<FiletransferHandle, oneshot::Sender<Result<Vec<u8>, ProtocolError>>>;
struct EventChannels {
voice_in: mpsc::Sender<InboundVoice>,
chat: mpsc::Sender<ChatMessage>,
@@ -84,6 +93,30 @@ async fn send_with_timeout<T: Send>(
}
}
fn drain_voice_packets_for_tick<T, E>(
voice_out_rx: &mut mpsc::Receiver<T>,
max_packets: usize,
mut send: impl FnMut(T) -> Result<(), E>,
) -> usize {
let mut drained = 0;
for _ in 0..max_packets {
let packet = match voice_out_rx.try_recv() {
Ok(packet) => packet,
Err(_) => break,
};
let _ = send(packet);
drained += 1;
}
drained
}
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
where
S: futures::Stream,
{
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
}
/// Pick the TeamSpeak `client_version`/platform/signature triple
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
/// `tsproto-types` at vendor-time) that best matches the *runtime*
@@ -179,6 +212,10 @@ enum Request {
client_id: u64,
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
},
DownloadFile {
path: String,
reply: oneshot::Sender<Result<Vec<u8>, ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -338,12 +375,45 @@ impl ProtocolClient {
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
}
async fn download_file(&self, path: String) -> Result<Vec<u8>, ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::DownloadFile { path, reply: tx })
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
}
/// Download the current avatar bytes for a TeamSpeak client UID.
pub async fn download_avatar(&self, client_uid: &str) -> Result<Vec<u8>, ProtocolError> {
self.download_file(avatar_download_path(client_uid)).await
}
/// Download the current channel/server icon bytes for an icon id.
pub async fn download_icon(&self, icon_id: u64) -> Result<Vec<u8>, ProtocolError> {
self.download_file(icon_download_path(icon_id)).await
}
/// Disconnect cleanly. Blocks until the task exits.
pub async fn disconnect(self) {
let (tx, rx) = oneshot::channel();
let request_path = async {
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
let _ = rx.await;
}
};
if tokio::time::timeout(DISCONNECT_REPLY_TIMEOUT, request_path)
.await
.is_err()
{
warn!(
target: "chanora_protocol",
timeout_ms = DISCONNECT_REPLY_TIMEOUT.as_millis() as u64,
"disconnect request did not complete before timeout"
);
}
}
/// Move our own client into a channel. `password` is optional
@@ -672,16 +742,20 @@ async fn connection_task(
// deadline so a server that never replies doesn't leak the
// reply channel — at most 3 s of pending state per move.
let mut pending_moves: PendingMoves = HashMap::new();
let mut pending_downloads: PendingDownloads = HashMap::new();
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
let mut poke_limiter = PokeLimiter::new();
// Main loop: pump events, service requests, forward voice.
loop {
// 1. Drain any outbound voice packets first — they're time-sensitive.
while let Ok(pkt) = voice_out_rx.try_recv() {
// 1. Send a bounded batch of outbound voice packets first — they're
// time-sensitive, but control requests must still make progress.
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
if let Err(e) = con.send_audio(pkt) {
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
}
}
Ok::<(), ()>(())
});
// 2. Advance event stream by at most one event with a small timeout.
let pump = async {
@@ -689,11 +763,16 @@ async fn connection_task(
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
};
match pump.await {
Ok(Some(Ok(item))) => {
match item {
Ok(Some(Ok(item))) => match item {
StreamItem::Audio(buf) => {
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
}
StreamItem::FileDownload(handle, result) => {
handle_download_stream_item(&mut pending_downloads, handle, result).await;
}
StreamItem::FiletransferFailed(handle, error) => {
handle_download_failure(&mut pending_downloads, handle, error);
}
other => handle_non_audio_stream_item(
&con,
other,
@@ -701,9 +780,9 @@ async fn connection_task(
&channels.activity,
&channels.delta,
&mut pending_moves,
&mut poke_limiter,
),
}
}
},
Ok(Some(Err(e))) => {
warn!(target: "chanora_protocol", error = %e, "event error");
// Some errors are transient; treat persistent ones
@@ -800,14 +879,28 @@ async fn connection_task(
client_id,
&channels,
&mut pending_moves,
&mut pending_downloads,
&mut voice_activity,
&mut poke_limiter,
)
.await;
let _ = reply.send(r);
}
Ok(Request::DownloadFile { path, reply }) => {
match con.download_file(TsChannelId(0), &path, None, None) {
Ok(handle) => {
pending_downloads.insert(handle, reply);
}
Err(e) => {
let _ = reply.send(Err(ProtocolError::FileTransfer(format!(
"start download {path}: {e}"
))));
}
}
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
let _ = reply.send(());
info!(target: "chanora_protocol", "clean disconnect");
exit!(DisconnectReason::UserRequested);
@@ -815,7 +908,7 @@ async fn connection_task(
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
exit!(DisconnectReason::UserRequested);
}
@@ -863,6 +956,7 @@ fn handle_non_audio_stream_item(
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves,
poke_limiter: &mut PokeLimiter,
) {
match item {
StreamItem::BookEvents(events) => {
@@ -924,17 +1018,25 @@ fn handle_non_audio_stream_item(
message,
} = ev
{
let mapped = match target {
tsclientlib::MessageTarget::Server => MessageTarget::Server,
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64),
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
let (mapped, poke_strength) = match target {
tsclientlib::MessageTarget::Server => (MessageTarget::Server, None),
tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None),
tsclientlib::MessageTarget::Client(id) => {
(MessageTarget::Client(id.0 as u64), None)
}
tsclientlib::MessageTarget::Poke(id) => {
let own_client_id =
con.get_state().ok().map(|state| state.own_client.0 as u64);
let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id);
(MessageTarget::Poke(id.0 as u64), Some(strength))
}
};
let _ = chat_tx.try_send(ChatMessage {
sender_id: ClientId(invoker.id.0 as u64),
sender_name: sanitize(&invoker.name),
message: sanitize(&message),
target: mapped,
poke_strength,
});
}
}
@@ -971,6 +1073,47 @@ fn handle_non_audio_stream_item(
}
}
async fn handle_download_stream_item(
pending_downloads: &mut PendingDownloads,
handle: FiletransferHandle,
result: FileDownloadResult,
) {
if let Some(reply) = pending_downloads.remove(&handle) {
let _ = reply.send(read_download_bytes(result).await);
}
}
fn handle_download_failure(
pending_downloads: &mut PendingDownloads,
handle: FiletransferHandle,
error: tsclientlib::Error,
) {
if let Some(reply) = pending_downloads.remove(&handle) {
let _ = reply.send(Err(ProtocolError::FileTransfer(error.to_string())));
}
}
const MAX_DOWNLOAD_SIZE: u64 = 10 * 1024 * 1024;
async fn read_download_bytes(result: FileDownloadResult) -> Result<Vec<u8>, ProtocolError> {
if result.size > MAX_DOWNLOAD_SIZE {
return Err(ProtocolError::FileTransfer(format!(
"download too large: {} bytes (max {})",
result.size, MAX_DOWNLOAD_SIZE
)));
}
let size = usize::try_from(result.size).map_err(|_| {
ProtocolError::FileTransfer(format!("download too large to buffer: {} bytes", result.size))
})?;
let mut stream = result.stream;
let mut bytes = vec![0_u8; size];
stream
.read_exact(&mut bytes)
.await
.map_err(|e| ProtocolError::FileTransfer(e.to_string()))?;
Ok(bytes)
}
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
host: address.to_string(),
@@ -1121,11 +1264,21 @@ async fn fetch_client_profile(
client_id: u64,
channels: &EventChannels,
pending_moves: &mut PendingMoves,
pending_downloads: &mut PendingDownloads,
voice_activity: &mut HashMap<u64, Instant>,
poke_limiter: &mut PokeLimiter,
) -> Result<ClientProfile, ProtocolError> {
let target_id = TsClientId(client_id as u16);
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = {
let (
database_id,
uid_b64,
has_optional,
has_connection,
is_own,
needs_server_groups,
needs_channel_groups,
) = {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
@@ -1158,7 +1311,9 @@ async fn fetch_client_profile(
build_command("servergrouplist", &[], &[]),
channels,
pending_moves,
pending_downloads,
voice_activity,
poke_limiter,
)
.await;
}
@@ -1168,7 +1323,9 @@ async fn fetch_client_profile(
build_command("channelgrouplist", &[], &[]),
channels,
pending_moves,
pending_downloads,
voice_activity,
poke_limiter,
)
.await;
}
@@ -1182,7 +1339,9 @@ async fn fetch_client_profile(
),
channels,
pending_moves,
pending_downloads,
voice_activity,
poke_limiter,
)
.await
{
@@ -1200,7 +1359,9 @@ async fn fetch_client_profile(
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
channels,
pending_moves,
pending_downloads,
voice_activity,
poke_limiter,
)
.await
{
@@ -1219,7 +1380,9 @@ async fn fetch_client_profile(
database_id,
channels,
pending_moves,
pending_downloads,
voice_activity,
poke_limiter,
)
.await
.ok()
@@ -1289,10 +1452,18 @@ async fn fetch_client_profile(
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
last_connected_unix_seconds: optional
.map(|info| info.last_connected.unix_timestamp())
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())),
.or_else(|| {
db_info
.as_ref()
.map(|info| info.last_connected.unix_timestamp())
}),
connections_total: optional
.map(|info| u64::from(info.connections_total))
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))),
.or_else(|| {
db_info
.as_ref()
.map(|info| u64::from(info.connections_total))
}),
online_seconds: connection
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
@@ -1325,14 +1496,10 @@ async fn fetch_client_profile(
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
packet_loss_client_to_server_total: net_stats
.map(|s| s.get_packetloss())
.or_else(|| {
connection.map(|info| info.client_to_server_packetloss_total)
}),
.or_else(|| connection.map(|info| info.client_to_server_packetloss_total)),
packet_loss_server_to_client_total: net_stats
.map(|s| s.get_packetloss_s2c_total())
.or_else(|| {
connection.and_then(|info| info.server_to_client_packetloss_total)
}),
.or_else(|| connection.and_then(|info| info.server_to_client_packetloss_total)),
})
}
@@ -1377,7 +1544,9 @@ async fn request_messages(
command: OutCommand,
channels: &EventChannels,
pending_moves: &mut PendingMoves,
pending_downloads: &mut PendingDownloads,
voice_activity: &mut HashMap<u64, Instant>,
poke_limiter: &mut PokeLimiter,
) -> Result<Vec<InMessage>, ProtocolError> {
let handle = command
.send_with_result(con)
@@ -1413,6 +1582,12 @@ async fn request_messages(
StreamItem::Audio(buf) => {
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
}
StreamItem::FileDownload(handle, result) => {
handle_download_stream_item(pending_downloads, handle, result).await;
}
StreamItem::FiletransferFailed(handle, error) => {
handle_download_failure(pending_downloads, handle, error);
}
other => handle_non_audio_stream_item(
con,
other,
@@ -1420,6 +1595,7 @@ async fn request_messages(
&channels.activity,
&channels.delta,
pending_moves,
poke_limiter,
),
}
}
@@ -1430,14 +1606,18 @@ async fn request_client_db_info(
dbid: tsclientlib::ClientDbId,
channels: &EventChannels,
pending_moves: &mut PendingMoves,
pending_downloads: &mut PendingDownloads,
voice_activity: &mut HashMap<u64, Instant>,
poke_limiter: &mut PokeLimiter,
) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages(
con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
channels,
pending_moves,
pending_downloads,
voice_activity,
poke_limiter,
)
.await?;
for message in messages {
@@ -1493,6 +1673,14 @@ fn uid_to_avatar_path(uid_b64: &str) -> String {
rendered
}
fn avatar_download_path(client_uid: &str) -> String {
format!("/avatar_{}", uid_to_avatar_path(client_uid))
}
fn icon_download_path(icon_id: u64) -> String {
format!("/icon_{icon_id}")
}
fn find_client_by_id<'a>(
clients: impl IntoIterator<Item = &'a Client>,
client_id: u64,
@@ -1876,10 +2064,13 @@ const _: () = {
#[cfg(test)]
mod tests {
use super::{
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout,
server_socket_from_config, sort_channels_tree_by, std_duration_millis,
ConnectConfig, SendTimeoutError,
avatar_download_path, bounded_drain_stream, client_profile_refresh_plan,
drain_voice_packets_for_tick, icon_download_path, is_server_query_client_type,
send_with_timeout, server_socket_from_config, sort_channels_tree_by,
std_duration_millis, ConnectConfig, ProtocolClient, Request, SendTimeoutError,
DISCONNECT_REPLY_TIMEOUT,
};
use futures::stream;
use std::time::Duration;
use tokio::sync::mpsc;
use tsproto_types::ClientType;
@@ -1975,6 +2166,16 @@ mod tests {
assert!(plan.needs_channel_groups);
}
#[test]
fn avatar_download_path_uses_uid_hex_encoding() {
assert_eq!(avatar_download_path("AQID"), "/avatar_abacad");
}
#[test]
fn icon_download_path_uses_unsigned_icon_id() {
assert_eq!(icon_download_path(42), "/icon_42");
}
#[test]
fn channel_sort_linked_list_under_one_parent() {
// Server emits four root-level channels in arbitrary HashMap
@@ -2122,6 +2323,83 @@ mod tests {
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
}
#[tokio::test]
async fn disconnect_request_send_is_bounded_when_request_channel_is_full() {
let (tx, _rx) = mpsc::channel(1);
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
tx.send(Request::Snapshot(reply_tx))
.await
.expect("seed first request");
let (disconnect_tx, _disconnect_rx) = tokio::sync::oneshot::channel();
let result = send_with_timeout(
&tx,
Request::Disconnect(disconnect_tx),
Duration::from_millis(10),
)
.await;
assert!(matches!(result, Err(SendTimeoutError::Timeout(_))));
}
#[tokio::test]
async fn protocol_client_disconnect_returns_when_request_channel_is_full() {
let (tx, _rx) = mpsc::channel(1);
let (snapshot_tx, _snapshot_rx) = tokio::sync::oneshot::channel();
tx.send(Request::Snapshot(snapshot_tx))
.await
.expect("seed first request");
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
let (_voice_in_tx, voice_in_rx) = mpsc::channel(1);
let (_lost_tx, lost_rx) = tokio::sync::oneshot::channel();
let (_chat_tx, chat_rx) = mpsc::channel(1);
let (_activity_tx, activity_rx) = mpsc::channel(1);
let (_delta_tx, delta_rx) = mpsc::channel(1);
let client = ProtocolClient {
tx,
voice_out_tx,
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
};
tokio::time::timeout(
DISCONNECT_REPLY_TIMEOUT + Duration::from_millis(100),
client.disconnect(),
)
.await
.expect("disconnect should not wait indefinitely for request channel capacity");
}
#[tokio::test]
async fn voice_drain_stops_at_per_tick_budget() {
let (tx, mut rx) = mpsc::channel(8);
for value in 0_u8..5 {
tx.send(value).await.expect("seed voice packet");
}
let mut sent = Vec::new();
let drained = drain_voice_packets_for_tick(&mut rx, 2, |value| {
sent.push(value);
Ok::<(), ()>(())
});
assert_eq!(drained, 2);
assert_eq!(sent, vec![0, 1]);
assert_eq!(rx.len(), 3);
}
#[tokio::test]
async fn disconnect_stream_drain_returns_after_timeout() {
let start = tokio::time::Instant::now();
bounded_drain_stream(stream::pending::<()>(), Duration::from_millis(10)).await;
assert!(start.elapsed() < Duration::from_millis(100));
}
}
fn forward_delta(
@@ -2204,9 +2482,7 @@ fn forward_delta(
old: PropertyValue::Channel(channel),
..
} => {
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
id: channel.id.0,
});
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
}
Event::PropertyChanged {
id: PropertyId::Channel(channel_id),
+4
View File
@@ -3,6 +3,8 @@
use serde::{Deserialize, Serialize};
pub use crate::poke_limiter::PokeStrength;
/// Opaque server-side channel identifier. Internal representation is
/// the upstream u64 but callers must treat it as opaque.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -54,6 +56,8 @@ pub struct ChatMessage {
pub message: String,
/// Target scope of this message.
pub target: MessageTarget,
/// Strength classification for poke notifications.
pub poke_strength: Option<PokeStrength>,
}
/// A server-activity notification derived from TeamSpeak bookkeeping events.
+7 -1
View File
@@ -35,12 +35,14 @@
mod adapter;
mod dto;
pub mod poke_limiter;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ProtocolDelta, ServerActivity, ServerSnapshot,
PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
};
pub use poke_limiter::PokeLimiter;
// Re-export the upstream voice types so chanora_audio can build outbound
// voice packets without taking a direct dependency on tsclientlib /
@@ -113,4 +115,8 @@ pub enum ProtocolError {
/// should never see this; if they do, it is a mapping bug here.
#[error("protocol backend: {0}")]
Backend(String),
/// A file transfer failed while downloading protocol-owned assets.
#[error("file transfer failed: {0}")]
FileTransfer(String),
}
+174
View File
@@ -0,0 +1,174 @@
//! Per-connection poke strength classification.
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Notification strength assigned to an inbound poke.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PokeStrength {
/// Poke should be surfaced at full strength.
Strong,
/// Poke is rate-limited but below overflow severity.
Suppressed,
/// Poke remains suppressed after repeated suppressed pokes.
SuppressedOverflow,
}
/// Per-connection poke limiter.
#[derive(Debug)]
pub struct PokeLimiter {
window: Duration,
entries: HashMap<u64, PokeEntry>,
}
#[derive(Debug)]
struct PokeEntry {
tokens: u8,
last_refill: Instant,
suppressed_in_window: u8,
}
impl PokeLimiter {
const CAPACITY: u8 = 2;
const OVERFLOW_THRESHOLD: u8 = 3;
/// Create a limiter using the default five-minute refill interval.
pub fn new() -> Self {
Self {
window: Duration::from_secs(5 * 60),
entries: HashMap::new(),
}
}
/// Record a poke at the current instant.
pub fn record(&mut self, sender_id: u64, own_client_id: Option<u64>) -> PokeStrength {
self.record_at(sender_id, own_client_id, Instant::now())
}
/// Record a poke at an injected instant.
pub fn record_at(
&mut self,
sender_id: u64,
own_client_id: Option<u64>,
now: Instant,
) -> PokeStrength {
if own_client_id == Some(sender_id) {
return PokeStrength::Suppressed;
}
let entry = self.entries.entry(sender_id).or_insert(PokeEntry {
tokens: Self::CAPACITY,
last_refill: now,
suppressed_in_window: 0,
});
if now.duration_since(entry.last_refill) >= self.window {
entry.tokens = Self::CAPACITY;
entry.last_refill = now;
entry.suppressed_in_window = 0;
}
if entry.tokens > 0 {
entry.tokens -= 1;
return PokeStrength::Strong;
}
entry.suppressed_in_window = entry.suppressed_in_window.saturating_add(1);
if entry.suppressed_in_window >= Self::OVERFLOW_THRESHOLD {
PokeStrength::SuppressedOverflow
} else {
PokeStrength::Suppressed
}
}
}
impl Default for PokeLimiter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_two_pokes_are_strong_third_is_suppressed() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Suppressed
);
}
#[test]
fn self_poke_is_suppressed_without_consuming_token() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(7), now), PokeStrength::Suppressed);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Strong
);
}
#[test]
fn overflow_after_three_suppressed_pokes_in_five_minutes() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Suppressed
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(3)),
PokeStrength::Suppressed
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(4)),
PokeStrength::SuppressedOverflow
);
}
#[test]
fn refill_after_interval_uses_injected_time_without_sleeping() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Suppressed
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60 + 1)),
PokeStrength::Strong
);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
[package]
name = "chanora_resolver"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
build = "build.rs"
[dependencies]
+3 -11
View File
@@ -346,9 +346,7 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
if removed_channel {
deltas.push(Delta::ChannelRemoved(id));
}
Reduction {
deltas,
}
Reduction { deltas }
}
_ => Reduction { deltas: vec![] },
},
@@ -412,14 +410,7 @@ pub fn reduce_reconnect_snapshot(
state: &mut Option<ServerState>,
snap: ServerSnapshot,
) -> Reduction {
let normalized = normalize_snapshot(snap);
*state = Some(ServerState::from_snapshot(normalized.clone()));
Reduction {
deltas: vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(normalized),
],
}
reduce(state, StateEvent::Snapshot(snap))
}
#[cfg(test)]
@@ -624,6 +615,7 @@ mod tests {
sender_name: "Alice".into(),
message: "hello".into(),
target: MessageTarget::Channel,
poke_strength: None,
};
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
assert!(disconnected.deltas.is_empty());
+737
View File
@@ -0,0 +1,737 @@
# File Transfer Design
**Date:** 2026-06-10
**Status:** Draft for review
**Scope:** Download files from TeamSpeak-compatible servers via the native client protocol, starting with avatars and icons.
**Direct upstream source:** `docs/architecture/sad.md` (SAD-067, SDD-MOD-009)
## 1. Goal
Chanora needs to download files stored on TeamSpeak-compatible servers. The most visible use cases are client avatars and server/channel/client icons. The file transfer mechanism is also used for channel file browser features, but this document scopes the initial design to avatar and icon retrieval only.
This document describes:
- How the TeamSpeak file transfer protocol works.
- How `tsclientlib` exposes it.
- How Chanora should integrate it following the existing protocol adapter pattern.
- How the result flows through the bridge to the Flutter UI layer.
Upload, channel file browsing, and file deletion are explicitly out of scope for the initial implementation.
## 2. Protocol Background
### 2.1 Two-Phase Transfer
TeamSpeak file transfer is a two-phase process:
1. **Command phase** — The client sends a command over the main encrypted UDP connection to request a transfer token (`ftkey`).
2. **Transfer phase** — The client opens a separate TCP connection to the server's file transfer port (default `30033`) and sends the `ftkey` to authenticate the transfer. Raw bytes flow over this TCP stream.
### 2.2 Relevant ServerQuery Commands
| Command | Direction | Purpose |
|---|---|---|
| `ftinitdownload` | Client → Server | Initialize a download. Returns `ftkey`, `port`, `size`. |
| `ftgetfileinfo` | Client → Server | Get metadata for one or more files. |
| `ftgetfilelist` | Client → Server | List files in a channel's file repository. |
| `ftinitupload` | Client → Server | Initialize an upload. |
| `ftlist` | Client → Server | List active file transfers. |
| `ftstop` | Client → Server | Stop a running transfer. |
| `ftdeletefile` | Client → Server | Delete a file. |
| `ftcreatedir` | Client → Server | Create a directory. |
| `ftrenamefile` | Client → Server | Rename or move a file. |
Initial scope uses only `ftinitdownload` and `ftgetfileinfo`.
### 2.3 File Paths
Files are addressed by a path scoped to a channel ID (`cid`):
- `cid=0` — Server-level file repository. Avatars and icons live here.
- `cid=N` (non-zero) — Channel-specific file repository.
Avatar path: `/avatar_<hex>` where `<hex>` is derived from the client's unique identifier (UID). Each byte of the base64-decoded UID is split into two nibbles, and each nibble maps to a letter `a` through `p` (0→a, 1→b, ..., 15→p).
Icon path: `/icon_<id>` where `<id>` is the icon's signed 64-bit integer ID. If negative, treat as unsigned for the path.
### 2.4 `ftinitdownload` Command
```
ftinitdownload clientftfid={id} name={path} cid={channelId} cpw={password} seekpos={seek} proto=0
```
Parameters:
| Parameter | Type | Description |
|---|---|---|
| `clientftfid` | `u16` | Arbitrary client-side transfer ID. |
| `name` | `string` | File path, e.g. `/avatar_abcdef`. |
| `cid` | `ChannelId` | Channel scope (0 = server). |
| `cpw` | `string` | Channel password. Empty for server-level. |
| `seekpos` | `u64` | Resume offset. 0 for a fresh download. |
| `proto` | `u8` | Protocol version. Always 0. |
Server response:
| Field | Type | Description |
|---|---|---|
| `clientftfid` | `u16` | Echo of the client transfer ID. |
| `serverftfid` | `u16` | Server-side transfer ID. |
| `ftkey` | `string` | One-time transfer key (hex). |
| `port` | `u16` | File transfer TCP port (usually 30033). |
| `size` | `u64` | File size in bytes. |
| `proto` | `u8` | Protocol version echo. |
| `ip` | `string` (optional) | Override IP for the TCP connection. |
### 2.5 TCP Transfer
After receiving the `ftkey`, the client:
1. Opens a TCP connection to `server_ip:port`.
2. Sends `ftkey` followed by a newline.
3. Reads exactly `size` bytes of raw file data.
4. Closes the TCP connection.
### 2.6 Permissions
File transfer requires the following permissions on the server:
| Permission | Needed for |
|---|---|
| `i_ft_file_download_power` | Downloading files. |
| `i_ft_needed_file_download_power` | Required download power on the channel/server. |
| `b_ft_ignore_password` | Bypassing channel passwords (not needed for avatars). |
Avatar downloads typically require only basic download power because avatars are in the server-level repository (`cid=0`), which is generally accessible.
### 2.7 Avatar Detection
When a client connects or updates, the server sends `client_flag_avatar` as a string (the avatar hash). If non-empty, the client has an avatar. The avatar is downloaded from `/avatar_<hex>` where `<hex>` is computed from the client's UID (not from the hash string itself — the hash is just a presence indicator).
## 3. tsclientlib Support
`tsclientlib` implements file transfer natively. The library handles the entire command + TCP flow internally:
### 3.1 Public API
```rust
// tsclientlib/src/lib.rs (relevant signatures)
impl Connection {
pub fn download_file(
&mut self,
channel_id: ChannelId,
path: &str,
channel_password: Option<&str>,
seek_position: Option<u64>,
) -> Result<FiletransferHandle>;
pub fn upload_file(
&mut self,
channel_id: ChannelId,
path: &str,
channel_password: Option<&str>,
size: u64,
overwrite: bool,
resume: bool,
) -> Result<FiletransferHandle>;
}
```
`download_file` sends the `ftinitdownload` command and returns a `FiletransferHandle(u16)` immediately. The actual transfer completes asynchronously.
### 3.2 Stream Items
The connection's event stream emits:
| StreamItem | When | Data |
|---|---|---|
| `StreamItem::FileDownload(FileDownloadResult)` | Server responds with `ftkey`; TCP connected and `ftkey` written | `{ size: u64, stream: TcpStream }` |
| `StreamItem::FileUpload(FileUploadResult)` | Upload ready | `{ seek_position: u64, stream: TcpStream }` |
| `StreamItem::FiletransferFailed(FiletransferHandle, Error)` | Transfer failed | Handle + error |
When `FileDownload` fires, tsclientlib has already:
1. Sent `ftinitdownload` over the encrypted UDP command channel.
2. Received the `ftkey`, `port`, and `size` from the server.
3. Opened a TCP connection to `server:port`.
4. Written the `ftkey` to the TCP socket.
The `TcpStream` in `FileDownloadResult` is ready to read; Chanora only needs to read exactly `size` bytes.
### 3.3 Avatar Helper
`tsproto-types` provides `Uid::as_avatar()` which computes the avatar filename from a UID. Chanora's existing `uid_to_avatar_path()` in `adapter.rs` does the same thing independently.
### 3.4 Doc-Comment Examples
tsclientlib's source contains usage examples in doc comments:
```rust
/// Download an icon:
/// con.download_file(ChannelId(0), &format!("/icon_{}", icon_id), None, None)
/// Upload an avatar:
/// con.upload_file(ChannelId(0), "/avatar", None, data.len() as u64, true, false)
```
## 4. Architecture Integration
### 4.1 Existing Pattern
The protocol adapter (`crates/chanora_protocol`) uses a single tokio task that owns the `tsclientlib::Connection`. All operations follow this pattern:
1. Define a `Request` enum variant with parameters and a `oneshot::Sender` for the reply.
2. Send the request through the `mpsc` channel to the connection task.
3. The connection task calls tsclientlib and resolves the oneshot.
File transfer fits this pattern exactly. The only difference is that the result arrives asynchronously via `StreamItem::FileDownload` rather than immediately from the command call.
### 4.2 Design
The file transfer integration adds:
1. **`Request` variants** for file download.
2. **A pending-downloads map** (`HashMap<FiletransferHandle, DownloadContext>`) in the connection task, mirroring the existing `pending_moves` pattern.
3. **`StreamItem::FileDownload` and `StreamItem::FiletransferFailed`** handling in the event loop.
4. **New DTOs** for file transfer results.
5. **Convenience methods** on `ProtocolClient` for avatar and icon downloads.
### 4.3 Layer Responsibilities
| Layer | Responsibility |
|---|---|
| `chanora_protocol` | Call `tsclientlib::download_file`, track pending transfers, read `TcpStream`, return bytes. No tsclientlib types leak. |
| `chanora_core` | Orchestrate when to download (e.g., on profile fetch or on avatar cache miss). |
| `chanora_bridge` | Expose typed `download_avatar` / `download_icon` commands to Flutter. |
| Flutter UI | Call bridge, display with `Image.memory()`. Cache in memory/image cache. |
### 4.4 Error Mapping
File transfer errors map to the existing `ProtocolError` variants:
| tsclientlib error | ProtocolError |
|---|---|
| Permission denied (TS3 error code) | `ServerRejected { code, message }` |
| File not found | `ServerRejected { code, message }` |
| Network/TCP failure | `Backend(String)` |
| Timeout | `Timeout` |
| Connection lost mid-transfer | `Lost(String)` |
## 5. Detailed Design
### 5.1 New Types in `dto.rs`
```rust
/// A downloaded file's raw content and metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DownloadedFile {
/// Raw file bytes.
pub data: Vec<u8>,
/// The server path that was requested.
pub path: String,
/// Channel ID the file was downloaded from.
pub channel_id: u64,
}
```
### 5.2 New Request Variants in `adapter.rs`
```rust
enum Request {
// ... existing variants ...
/// Download a file from the server's file repository.
DownloadFile {
/// Channel ID. 0 for server-level (avatars, icons).
channel_id: u64,
/// File path, e.g. "/avatar_abcdef" or "/icon_12345".
path: String,
/// Channel password. None for server-level files.
channel_password: Option<String>,
/// Reply channel for the result.
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
},
}
```
### 5.3 Pending Downloads Map
```rust
type PendingDownloads = HashMap<tsclientlib::FiletransferHandle, PendingDownload>;
struct PendingDownload {
path: String,
channel_id: u64,
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
}
```
### 5.4 Event Loop Handling
In the connection task's main loop, add handling for file transfer stream items:
```rust
// In handle_non_audio_stream_item or in the main loop:
StreamItem::FileDownload(result) => {
// result: FileDownloadResult { size, stream }
// Look up the handle in pending_downloads
// Use tokio::io::AsyncReadExt::read_exact to read 'size' bytes
// Resolve the oneshot with DownloadedFile
}
StreamItem::FiletransferFailed(handle, error) => {
// Look up the handle in pending_downloads
// Resolve the oneshot with ProtocolError::Backend
}
```
The TCP read from the `TcpStream` is an async operation. Since the connection task already runs in a tokio context, the read can be done inline. However, for large files this would block the main event loop. Two approaches:
**Option A: Read inline (simple, good for small files like avatars)**
Avatars are typically under 100 KB. Reading them inline in the event loop is acceptable and avoids complexity.
**Option B: Spawn a reader task**
For future channel-file-browser support with potentially large files, spawn a separate tokio task that reads the stream and sends the result back.
**Recommendation:** Start with Option A. The initial scope is avatars and icons (small files). Refactor to Option B when channel file browsing is implemented.
### 5.5 Request Handling
When the connection task receives `Request::DownloadFile`:
```rust
Ok(Request::DownloadFile { channel_id, path, channel_password, reply }) => {
let ts_channel_id = TsChannelId(channel_id);
match con.download_file(ts_channel_id, &path, channel_password.as_deref(), None) {
Ok(handle) => {
pending_downloads.insert(handle, PendingDownload {
path,
channel_id,
reply,
});
}
Err(e) => {
let _ = reply.send(Err(ProtocolError::Backend(
format!("download_file init: {e}")
)));
}
}
}
```
### 5.6 Public API on `ProtocolClient`
```rust
impl ProtocolClient {
/// Download a file from the server's file repository.
/// `channel_id` 0 means server-level (avatars, icons).
pub async fn download_file(
&self,
channel_id: u64,
path: String,
channel_password: Option<String>,
) -> Result<DownloadedFile, ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::DownloadFile { channel_id, path, channel_password, reply: tx })
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
}
/// Download a client's avatar image. Returns raw image bytes.
/// Pass the `avatar_path` from `ClientProfile`.
pub async fn download_avatar(
&self,
avatar_path: String,
) -> Result<DownloadedFile, ProtocolError> {
self.download_file(0, avatar_path, None).await
}
/// Download a server, channel, or client icon by its icon ID.
pub async fn download_icon(
&self,
icon_id: i64,
) -> Result<DownloadedFile, ProtocolError> {
let unsigned_id = icon_id as u64;
let path = format!("/icon_{}", unsigned_id);
self.download_file(0, path, None).await
}
}
```
### 5.7 Exports in `lib.rs`
```rust
pub use dto::DownloadedFile;
```
### 5.8 Bridge Layer
In `crates/chanora_bridge/src/api.rs`, add:
```rust
pub async fn download_avatar(&self, avatar_path: String) -> Result<Vec<u8>, BridgeError> {
self.protocol
.download_avatar(avatar_path)
.await
.map(|file| file.data)
.map_err(BridgeError::Protocol)
}
```
### 5.9 Flutter Integration
Flutter side:
1. Call `clientProfile()` to get `ClientProfile` (already exists).
2. Check if `avatarPath` is non-empty.
3. Call bridge `downloadAvatar(avatarPath)` to get `Uint8List`.
4. Display with `Image.memory(bytes)`.
Caching strategy:
- In-memory: Use Flutter's standard `ImageCache` or a simple `Map<String, Uint8List>` keyed by avatar path.
- Disk: Consider caching to local storage for offline display. This is a follow-up decision, not MVP scope.
- The avatar path already encodes the UID, so it can serve as a cache key.
## 6. Avatar Path Computation
Chanora already has this implemented in `adapter.rs`:
```rust
fn uid_to_avatar_path(uid_b64: &str) -> String {
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
let mut rendered = String::with_capacity(decoded.len() * 2);
for byte in decoded {
rendered.push((b'a' + (byte >> 4)) as char);
rendered.push((b'a' + (byte & 0x0f)) as char);
}
rendered
}
```
This maps each nibble to `a` through `p` (0→a, 1→b, ..., 15→p), matching the canonical TeamSpeak implementation.
The full avatar path is constructed as:
```rust
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
String::new()
} else {
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
};
```
This is already correct and used in `ClientProfile.avatar_path`. No changes needed.
## 7. Threading and Concurrency
| Concern | Design |
|---|---|
| TCP read blocking the event loop | For avatar/icon sizes (< 100 KB typically), inline async read is acceptable. Spawn a reader task for larger files when channel file browsing is added. |
| Multiple concurrent downloads | `pending_downloads` is a HashMap keyed by `FiletransferHandle`. Multiple downloads can be in flight simultaneously. tsclientlib assigns unique handles. |
| Download timeout | Add a deadline to pending downloads (e.g., 30 seconds). Sweep expired entries similar to the existing `pending_moves` sweep. |
| Cancellation on disconnect | When the connection task exits, all pending oneshot senders are dropped, which resolves the caller's await with a `RecvError`. The caller maps this to `ProtocolError::Lost`. |
## 8. Diagnostic and Security Considerations
### 8.1 Diagnostic Redaction
- File transfer paths may contain user-identifying information (UID-derived avatar names). These should be registered for diagnostic redaction if they appear in log output.
- File contents (avatar images) must not appear in log output or diagnostic exports.
### 8.2 Security
- The `ftkey` is a one-time token and must not be logged.
- TCP file transfer connections are not encrypted. This is a TeamSpeak protocol limitation, not a Chanora design choice. Avatar data is public (visible to anyone on the server), so the risk is acceptable.
- File download does not require secrets beyond the existing authenticated connection.
### 8.3 Privacy
- Avatar downloads reveal to the server that the user is viewing a specific client's avatar. This is inherent in the protocol.
- Chanora should not download avatars proactively for all clients. Download only when the UI needs to display a specific avatar (lazy/on-demand).
## 9. Out of Scope
The following are explicitly deferred:
- File upload (avatar upload, channel file upload).
- Channel file browser (listing, creating directories, deleting, renaming).
- Resumable downloads (seek position > 0).
- File transfer progress reporting.
- myTeamSpeak avatar resolution (the `client_myteamspeak_avatar` field).
- In-memory hot cache in Rust (Flutter's `ImageCache` handles decoded image caching; add Rust-side layer only if profiling shows need).
- Upload, file browser, and channel file management.
## 10. Cache Architecture
### 10.1 Layer Ownership
| Layer | Responsibility | Storage |
|---|---|---|
| `chanora_protocol` | Download raw bytes from server. No caching logic. | None |
| `chanora_cache` | Content-addressed blob store backed by `cacache`: crash-safe writes, SSRI integrity verification, key validation, eviction, clear. Separate crate from `chanora_storage`. | Platform cache directory |
| `chanora_core` | Session-aware cache orchestration: check freshness, coalesce requests, rate-limit downloads, persist to disk via `chanora_cache`. | Delegates to `chanora_cache` |
| `chanora_bridge` | Expose typed `download_avatar` / `clear_file_cache` / `file_cache_size` to Flutter. | None |
| Flutter | Display via `Image.memory`. Standard `ImageCache` for hot memory caching. Evict from `ImageCache` when hash changes. | In-memory only |
### 10.2 Why Separate `chanora_cache` Crate
`chanora_cache` is a separate crate from `chanora_storage` for three reasons:
1. **Different durability semantics.** `chanora_storage` holds identity, bookmarks, and connection profiles — data the user explicitly created. `chanora_cache` holds downloaded blobs that are fully reconstructible from the server. Losing the cache is an inconvenience, not data loss.
2. **Different backup semantics.** Cache should be excluded from backups; persistent storage should be included. Platform conventions (iOS `Library/Caches/` vs `Library/Application Support/`) reflect this distinction.
3. **Different directory placement.** Cache lives in the platform's cache directory (OS may evict under storage pressure on mobile). Persistent storage lives in the support directory.
The cache wraps the `cacache` crate for production-tested crash safety and integrity verification. It does not share `chanora_storage`'s crate or directory, and does not reimplement cacache's atomic write or content-addressing logic.
### 10.3 Why Hybrid (Rust Disk + Flutter Memory)
- Flutter's built-in `ImageCache` is an LRU in-memory cache (default 1000 images / 100 MiB). It handles hot display caching automatically when you use `MemoryImage`.
- Flutter has no built-in disk cache. `cached_network_image` / `flutter_cache_manager` are designed for HTTP URLs, not custom binary protocol data.
- Rust already owns the protocol, the connection state, and the anti-flood budget. Putting disk cache here avoids a feedback loop across the bridge.
### 10.4 Cache Storage
`chanora_cache` wraps the `cacache` crate for its on-disk storage. The physical layout is managed by `cacache`:
```
<app_cache_dir>/chanora/
blobs/ ← cacache content store root
content-v2/ ← content-addressed by SHA-512
<sha512-hex>/
data ← raw blob bytes
tmp/ ← temp files (in-flight writes)
index-v2/ ← entry index (key → content mapping)
```
Chanora's `BlobCache` maps protocol keys to `cacache` string keys:
| Protocol key | cacache key | Example |
|---|---|---|
| Avatar MD5 | `"av_<md5hex>"` | `"av_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"` |
| Icon CRC32 | `"ic_<crc32u>"` | `"ic_123456789"` |
**Why `cacache`:**
1. **Crash safety.** Production-tested atomic writes (temp → rename). Handles partial writes, power loss, crash mid-write. No custom crash safety code to maintain.
2. **Integrity verification.** SSRI integrity check on every `read()`. Detects corruption, bit rot, partial writes automatically. Better than custom "delete on read failure".
3. **Content dedup.** Same bytes stored once regardless of key. Same avatar on two servers = stored once automatically.
4. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. Crash safety and integrity are the hard parts — `cacache` owns them.
**Why no `<server_uid>` subdirectory:** The protocol uses content-addressed identifiers. `client_flag_avatar` is the MD5 of the avatar bytes — a given avatar hash always maps to the same bytes regardless of which server the user is on. Same avatar on two servers = same content = stored once by `cacache`. This is a deliberate dedup advantage over per-server namespacing.
**Why no metadata sidecars:** Content is immutable (a given hash always maps to the same bytes). `cacache` manages its own entry index with timestamps. No custom metadata files needed.
**Key validation rules:**
| Prefix | Key format | Validation |
|---|---|---|
| `av_` | `av_<32 hex chars>` | MD5 is exactly 32 hex characters |
| `ic_` | `ic_<1-10 digit number>` | CRC32 unsigned, 04294967295 |
Keys failing validation are rejected at the `BlobCache` API boundary. This prevents path traversal or malformed filenames on disk.
**Platform paths** (Flutter passes the base directory into Rust at startup, matching the existing `initStorage` pattern):
| Platform | Cache directory |
|---|---|
| Android | `context.cacheDir/chanora/` (via `getCacheDir()`) |
| iOS | `Library/Caches/chanora/` (via `getApplicationCacheDirectory()`) |
| macOS | `~/Library/Caches/chanora/` |
| Windows | `%LOCALAPPDATA%/chanora/cache/` |
| Linux | `$XDG_CACHE_HOME/chanora/` or `~/.cache/chanora/` |
Flutter already resolves platform-specific paths. The same `getApplicationCacheDirectory()` call that is available in `path_provider` across all Chanora target platforms should be used. This follows the existing pattern where `app_bootstrap.dart` calls `getApplicationSupportDirectory()` for persistent storage; avatar/icon cache uses the cache-equivalent directory instead.
The cache init call is separate from storage init:
```rust
// Bridge init (Flutter calls these at startup)
pub fn init_storage(support_dir: String) -> Result<(), BridgeError>; // existing
pub fn init_cache(cache_dir: String) -> Result<(), BridgeError>; // new
```
### 10.5 Cache Freshness Strategy
The `client_flag_avatar` field on each client is the authoritative freshness signal:
```
On connect / on client list update:
For each visible client with non-empty avatar_hash:
cache_key = "av_<avatar_hash>"
if cacache entry exists for "av_<avatar_hash>":
use cached file (zero downloads)
else:
enqueue download for avatar_path with expected hash = avatar_hash
On avatar_hash change for a client:
The new hash produces a different cacache key.
The old entry remains until eviction or manual clear.
The new file is downloaded on demand.
```
This means:
- **First connect:** No cache hits. Downloads happen lazily as the UI requests avatars.
- **Reconnect to same server:** All avatars hit cache instantly (hashes match keys). Zero downloads.
- **User changes avatar:** New hash = new cache key. Old entry becomes orphan. New file downloads on next UI request.
- **Same user on different server:** Same avatar hash = same cached file. Cross-server dedup for free.
### 10.6 Anti-Flood and Download Timing
TeamSpeak servers enforce anti-flood rate limiting. Downloading all avatars eagerly on connect would trigger it on servers with many users.
**Strategy: lazy + throttled prefetch**
| Phase | What | Rate |
|---|---|---|
| Connect settle (first 2-5 s) | Do nothing. Let the initial state snapshot and channel tree arrive. | — |
| After settle | UI requests avatars for visible clients in the current channel. These trigger downloads one at a time. | Max 1-2 concurrent downloads per server |
| Channel switch | UI requests avatars for newly visible clients. | Same throttle |
| Background prefetch (optional, future) | Low-priority downloads for clients in adjacent channels. | 1 request per 500 ms |
**Anti-flood handling:**
- If the server responds with an anti-flood error (TS3 error code `0x0701` = `client_could_not_be_banned` / flood-related), back off the download queue.
- Implement a simple semaphore in `chanora_core`: max 1-2 concurrent downloads.
- If a download gets a flood error, pause the queue for 5 seconds, then resume at reduced rate.
### 10.7 Retry on Failure
| Failure type | Strategy |
|---|---|
| Transient (network timeout, TCP reset) | Retry with exponential backoff: 5 s, 30 s, 2 min, 10 min. Cap at 10 min. |
| Server flood limit hit | Pause queue 5 s, then resume at reduced rate. Do not count as a per-file retry. |
| Permission denied (no download power) | Do not retry. Record negative cache entry. Only retry if hash changes. |
| File not found (avatar removed) | Do not retry. Record negative cache entry. Clear when hash changes or becomes empty. |
| Connection lost | All pending downloads fail. On reconnect, cache check runs fresh with current hashes. |
**Negative cache:** In-memory `HashMap<String, Instant>` with 5-minute TTL. Keys like `"av_<hash>"` or `"ic_<id>"` that received permanent errors are stored with an expiry. On lookup, expired entries are treated as absent. Cleared entirely on reconnect.
### 10.8 Request Coalescing
Multiple UI widgets may request the same avatar simultaneously (e.g., channel list + chat view + client info sheet).
**Pattern:** In `chanora_core`'s `FileTransferService`, maintain an in-flight map:
```rust
HashMap<String, tokio::task::JoinHandle<Result<Vec<u8>, FileTransferError>>>
```
- First request: start download, store handle.
- Subsequent requests for same key: await the same handle.
- When handle completes: write to cache, wake all waiters, remove from map.
### 10.9 Cache Eviction and Size Limits
**MVP approach:**
- No automatic size-based eviction in MVP. Avatars are small (typically 10-100 KB). Even 1000 avatars = ~50-100 MB.
- Rely on platform cache directory semantics (OS may evict under storage pressure on mobile).
- Old hash files accumulate but are harmless.
**Post-MVP:**
- `BlobCache::evict(max_bytes)` — walk `cacache::ls()` entries, sort by timestamp (oldest first), delete until total size < `max_bytes`. `cacache` manages timestamps internally. No metadata sidecars needed.
- Or simpler: `BlobCache::evict_older_than(duration)` — delete entries with timestamp older than N days.
- Call on startup and periodically (e.g., every 24 hours or on app resume).
### 10.10 User-Initiated Cache Clear
Add a bridge method:
```rust
pub fn clear_file_cache(&self) -> Result<(), BridgeError> {
// Delete the entire blobs/ directory contents
// Flutter evicts all avatar/icon-related entries from ImageCache
}
pub fn file_cache_size(&self) -> Result<u64, BridgeError> {
// Walk blobs/ and sum file sizes
}
```
Flutter side:
```dart
// In settings or storage management UI:
onPressed: () async {
await api.clearFileCache();
PaintingBinding.instance.imageCache.clear();
}
```
This should be exposed in the app's settings UI under a "Clear cache" or "Storage management" section.
### 10.11 Storage Clear Across Servers
Since the cache is flat with content-addressed keys (no server namespacing):
- Connecting to a different server does not conflict — same avatar hash = same file.
- Avatars unique to the old server remain cached. If a user on the new server has the same avatar (same hash), it hits cache instantly (cross-server dedup).
- Cache clear removes all cached data regardless of which server it came from.
### 10.12 Flutter Display Strategy
**Option A: Bytes across bridge (simpler, recommended for MVP)**
Rust returns `Vec<u8>` across the bridge. Flutter uses `Image.memory(bytes)`.
```dart
final bytes = await api.downloadAvatar(clientUid: uid);
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(Uint8List.fromList(bytes));
} else {
return CircleAvatar(child: Text(initials)); // fallback
}
```
Flutter's `ImageCache` caches the decoded image in memory automatically. Same avatar bytes = cache hit in memory.
**Option B: File path across bridge (better for large images, future)**
Rust writes to disk and returns the file path. Flutter uses `FileImage`.
```dart
final path = await api.getAvatarPath(avatarHash: hash);
if (path != null) {
return Image.file(File(path));
} else {
return CircleAvatar(child: Text(initials));
}
```
`FileImage` does not watch for file changes. When the hash changes, the UI must evict the old entry from `ImageCache` using `PaintingBinding.instance.imageCache.evict(key)`.
**Recommendation:** Start with Option A for MVP. It avoids file-path cross-platform complications and works well for small avatar files. The bridge already returns `Vec<u8>` for the download result.
## 11. Implementation Sequence
| Phase | Scope | What |
|---|---|---|
| Phase 1 | Protocol download | `Request::DownloadFile`, `StreamItem::FileDownload` handling, `ProtocolClient::download_avatar()` / `download_icon()`. No caching. |
| Phase 2 | Bridge + Flutter display | Bridge `downloadAvatar()`, Flutter `Image.memory()`, initials fallback. Still no caching — every view re-downloads. |
| Phase 3 | Rust disk cache | New `chanora_cache` crate: `cacache`-backed content-addressed blob store (`BlobCache`), key validation, mtime-based eviction, `init_cache` bridge call. |
| Phase 4 | Session orchestration | `chanora_core` `FileTransferService`: request coalescing, rate limiter (semaphore), negative cache (5 min TTL), retry backoff. |
| Phase 5 | Cache management | Bridge `clearFileCache()` + `fileCacheSize()`, Flutter settings UI, eviction on startup. |
Phase 1 and 2 deliver visible value (avatars in the UI). Phase 3-5 add robustness.
## 12. References
| Reference | Use |
|---|---|
| `ReSpeak/tsdeclarations` `Messages.toml` lines 828-830 | `ftinitdownload` command declaration |
| `ReSpeak/tsdeclarations` `Messages.toml` lines 590 | `FileDownload` response structure |
| `ReSpeak/tsdeclarations` `ts3protocol.md` | Low-level TeamSpeak protocol specification |
| `ReSpeak/tsclientlib` `src/lib.rs` lines 956-1005 | `download_file` / `upload_file` public API |
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1371-1427 | `StreamItem::FileDownload` handling |
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1630-1672 | Outgoing init commands |
| `Multivit4min/TS3-NodeJS-Library` `src/transport/FileTransfer.ts` | Reference TCP transfer implementation |
| `Speckmops/ts3admin.class` `lib/ts3admin.class.php` lines 1352-1370 | Reference avatar download flow |
| `docs/architecture/sad.md` SAD-067, SDD-MOD-009 | Protocol adapter boundary rules |
| `crates/chanora_protocol/src/adapter.rs` lines 1561-1568 | Existing `uid_to_avatar_path` implementation |
File diff suppressed because it is too large Load Diff
+770
View File
@@ -0,0 +1,770 @@
# File Transfer Cache Research
**Date:** 2026-06-10
**Status:** Research complete, design implications noted (updated with TeaSpeak server findings)
**Companion to:** `docs/architecture/file-transfer-design.md`
**Purpose:** Factual findings from protocol analysis, existing client implementations, and cross-platform research that inform the cache architecture decision.
---
## 1. TS3 Protocol Identity Semantics
### 1.1 Avatar Identity
| Aspect | Value |
|---|---|
| Protocol field | `client_flag_avatar` |
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:200`) |
| Meaning | MD5 hash of the avatar file bytes |
| Scope | Per-client per-server — a user can have different avatars on different servers |
| Freshness | Automatically up-to-date for any client "in view" (`FLAG_CLIENT_VIEW`) |
| Empty value | No avatar set |
**Key fact:** Identical avatar image bytes produce the **same** `client_flag_avatar` hash on any TS3 server. The hash is a content fingerprint, not a server-assigned identifier.
**Download path on server:** `/avatar_<base64HashClientUID>` — the filename is derived from the client's unique identifier (UID), not from the content hash. The content hash is communicated separately via `client_flag_avatar`.
**Sources:**
- TeaSpeakLibrary `PropertyDefinition.h:200`: `PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "", TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}`
- TS3AudioBot avatar upload: computes MD5 of image bytes, then sets `client_flag_avatar` to that hash ([`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341))
- TS3 NodeJS Library: avatar filename is `avatar_${clientBase64HashClientUID}` ([`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313))
- TS3 PHP Framework: avatar name derivation from UID ([`ts3phpframework/src/Node/Client.php:288-307`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Client.php#L288-L307))
### 1.2 Icon Identity
| Aspect | Value |
|---|---|
| Protocol fields | `channel_icon_id`, `client_icon_id`, `virtualserver_icon_id` |
| Type | `TYPE_UNSIGNED_NUMBER` (TeaSpeakLibrary `PropertyDefinition.h:83,146,217`) |
| Meaning | CRC32 (unsigned) of the icon file bytes |
| Scope | Per-entity per-server — but CRC32 is content-derived |
| Download path | `/icon_<unsigned_crc32>` |
**Key fact:** Identical icon bytes produce the **same** CRC32 on any TS3 server. The icon ID is a content fingerprint. The upload process computes `crc32.unsigned(data)` and stores at `/icon_<id>`.
**CRC32 collision caveat:** CRC32 is only 32 bits. Different icon content can theoretically produce the same CRC32. Qint's `filecache.rs` explicitly notes this: "there could be collisions because only CRC-32 is used." ForChanora's purposes (small icons, not security-critical), this is acceptable.
**Sources:**
- TeaSpeakLibrary `PropertyDefinition.h:83,146,217`: all icon IDs are `TYPE_UNSIGNED_NUMBER`
- TS3 NodeJS Library `uploadIcon()`: computes `crc32.unsigned(data)`, uploads to `/icon_<id>` ([`TS3-NodeJS-Library/src/TeamSpeak.ts:2234-2241`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/TeamSpeak.ts#L2234-L2241))
- TS3 PHP Framework: icon path uses `/icon_<unsigned id>` ([`ts3phpframework/src/Node/Node.php:137-146`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Node.php#L137-L146))
- TS3 community forum: "The filename itself is the result of the CRC32 checksum" (TeamSpeak staff)
### 1.3 Implication for Cache Design
Both avatars and icons are **content-addressed by the protocol itself**:
| Asset | Content hash source | Same content across servers? |
|---|---|---|
| Avatar | `client_flag_avatar` = MD5 of bytes | Same bytes → same hash → same ID |
| Icon | `icon_id` = CRC32 of bytes | Same bytes → same CRC32 → same ID |
This means a **flat content-addressed blob store** can achieve zero-duplication without any per-server directories, hardlinks, or ref-counting.
---
## 2. Virtual Server Identity
### 2.1 Server UID
| Aspect | Value |
|---|---|
| Protocol field | `virtualserver_unique_identifier` |
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:22`) |
| Generated by | The server instance, on creation |
| Globally unique? | **Not guaranteed** — locally generated, no central registry |
| Stable? | Yes — persists across restarts of the same virtual server |
**Key fact:** `virtualserver_unique_identifier` is generated by each TS3 server. Two physically different servers could theoretically produce the same UID. It is **not safe as a global cache key**.
### 2.2 What Chanora Currently Tracks
| Layer | Server identity fields | Source |
|---|---|---|
| Protocol adapter (`adapter.rs:1752-1755`) | `server_name`, `welcome_message`, `platform`, `version` | `state.server.*` from tsclientlib |
| DTO (`ServerSnapshot`) | `server_name`, `welcome_message`, `platform`, `version` | No UID field |
| Bridge (`BridgeSnapshot`) | Same as DTO | Same |
| Storage (bookmarks) | Keyed by `host` (hostname:port) | SQLite `WHERE host = ?1` |
| Core (recent servers) | `cfg.address` as host | Auto-saved on connect |
**Chanora does not currently plumb `virtualserver_unique_identifier` through the DTO stack.** The field exists in tsclientlib's state but is not extracted.
### 2.3 Implication for Cache Design
Using `virtualserver_unique_identifier` as the sole cache key is risky (not globally unique). Using connection address (`host:port`) is safe but duplicates cache entries when the same server is accessed via different addresses.
**Recommendation:** For a content-addressed blob store, server identity is only needed for per-server metadata (eviction, "clear cache for this server"), not for the blob key itself. The blob key is the content hash.
---
## 3. Existing TS3 Client Cache Implementations
### 3.1 Qint (tsclientlib-based, Tauri + Rust)
**Architecture:** Per-server directory with SQLite metadata.
```
<cache>/files/<server-uid>/<channel-id>/<base64(path)>
```
**Avatar handling:**
- Avatar state stored per `(server, client)` row in SQLite
- On avatar hash change, deletes the cached `/avatar_<uid>` file for that server
- Avatar download path: `/avatar_<uid_base64>`
**Icon handling:**
- Icons path-cached with CRC32
- Code comments note CRC32 collisions and freshness by mtime
- Qint explicitly deletes and re-downloads when icon mtime changes
**Dedup:** None. Same avatar on 5 servers = 5 stored copies.
**Sources:**
- [`Qint/proxy/src/filecache.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/filecache.rs#L1-L6): "Stores files transferred via the TS3 file transfer protocol. This includes icons and avatars."
- [`Qint/proxy/src/db/mod.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/db/mod.rs#L1158-L1176): avatar hash change triggers delete
- [`Qint/src-tauri/src/cmd.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/src-tauri/src/cmd.rs#L524-L539): file download command
### 3.2 TS3 Official Client (closed source)
**Architecture:** Lazy cache with SDK callbacks.
- `getAvatar()` returns cached path if present; otherwise triggers download
- `onAvatarUpdated` callback fires when avatar is downloaded or deleted
- Cache paths (from community documentation):
- Windows: `%LOCALAPPDATA%\TeamSpeak\Cache\Default`
- Linux: `~/.cache/TeamSpeak/Default`
- macOS: `~/Library/Caches/TeamSpeak/Default`
- SDK also exposes `CLIENT_MYTS_AVATAR` / `client_myteamspeak_avatar` for cross-server myTeamSpeak avatars
**Sources:**
- [`ts3client-pluginsdk/src/plugin.c`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/src/plugin.c#L384-L396): `getAvatar()` and `onAvatarUpdated`
- [`ts3client-pluginsdk/public_rare_definitions.h`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/include/teamspeak/public_rare_definitions.h#L284-L313): `CLIENT_FLAG_AVATAR`, `CLIENT_MYTS_AVATAR`
- Community: [clear cache](https://community.teamspeak.com/t/clear-cache/41511), [broken icons](https://community.teamspeak.com/t/server-icons-are-displaying-a-broken-image-issues-with-local-cache/58680)
### 3.3 TeaSpeak Client (TypeScript + C++ native)
**Architecture:** Browser Cache API for images, per-server own-avatar storage.
**Key components (from `.d.ts` type declarations):**
- `AvatarManager` — per-connection (`FileManager`) avatar handler
- `cachedAvatars` (private) — in-memory cache of `ClientAvatar` objects
- `updateCache(clientAvatarId, clientAvatarHash)` — updates cache when hash changes
- `resolveAvatar(clientAvatarId, avatarHash?, cacheOnly?)` — resolves avatar by ID
- `flush_cache()` — clears cache
- `create_avatar_download(client_avatar_id)` — initiates file transfer
- `ClientAvatar` — tracks individual avatar state
- `clientAvatarId` — derived from client UID via `uniqueId2AvatarId()`
- `currentAvatarHash` — the `client_flag_avatar` value
- State machine: `unset``loading``loaded` / `errored`
- `loadingTimestamp` — when download started
- `ImageCache` — generic image cache using browser Cache API
- `resolveCached(key, maxAge?)` — check if cached
- `putCache(key, value, type?, headers?)` — store
- `cleanup(maxAge)` — evict old entries
- `reset()` — clear all
- `isPersistent()` — whether cache persists to disk
- `OwnAvatarStorage` — user's own avatar, keyed by `serverUniqueId + mode`
- `loadAvatarImage(serverUniqueId, mode)` — load own avatar for a server
- `updateAvatar(serverUniqueId, mode, target)` — update own avatar
- `avatarUploadSucceeded(serverUniqueId)` — move from "uploading" to "server" state
- Stores `LocalAvatarInfo`: fileName, fileSize, **fileHashMD5**, timestamps, contentType
- `FileManager` — per-connection file transfer manager
- `MAX_CONCURRENT_TRANSFERS` — transfer concurrency limit
- `avatars: AvatarManager` — avatar subsystem
- `initializeFileDownload(options)` — start download (path, name, channel, target)
- `deleteIcon(iconId: number)` — delete icon by ID
- `FileTransfer` — transfer state machine
- States: `PENDING → INITIALIZING → CONNECTING → RUNNING → FINISHED / ERRORED / CANCELED`
- `InitializedTransferProperties`: serverTransferId, transferKey, **addresses[]**, protocol, seekOffset, fileSize
- Multiple addresses returned by server for file transfer (failover)
- `localIconCache: ImageCache` — global icon cache (singleton)
**Sources:**
- TeaSpeak-Client `imports/shared-app/file/Avatars.d.ts` — ClientAvatar, AbstractAvatarManager
- TeaSpeak-Client `imports/shared-app/file/LocalAvatars.d.ts` — AvatarManager
- TeaSpeak-Client `imports/shared-app/file/LocalIcons.d.ts` — localIconCache
- TeaSpeak-Client `imports/shared-app/file/ImageCache.d.ts` — ImageCache (browser Cache API)
- TeaSpeak-Client `imports/shared-app/file/FileManager.d.ts` — FileManager, transfer API
- TeaSpeak-Client `imports/shared-app/file/Transfer.d.ts` — FileTransfer, state machine, error types
- TeaSpeak-Client `imports/shared-app/file/OwnAvatarStorage.d.ts` — own avatar per-server storage
- TeaSpeak-Client `native/serverconnection/test/js/ft.ts` — file transfer test (TCP + ftkey protocol)
### 3.4 TS3AudioBot (C#)
**Architecture:** No local avatar cache. Avatar upload is hash-driven.
- Uploads avatar bytes to `/avatar`, computes MD5, sets `client_flag_avatar` to that hash
- Bot avatar selection reads local files from an `avatars/` directory
- No caching of other users' avatars
**Sources:**
- [`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341)
- [`TS3AudioBot/Bot.cs:420-470`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TS3AudioBot/Bot.cs#L420-L470)
### 3.5 TS3 NodeJS Library
**Architecture:** No local cache. Downloads on demand.
- Avatar filename: `avatar_${clientBase64HashClientUID}`
- `getAvatar()` downloads directly — no caching layer
- Tests assert the exact `/avatar_<base64uid>` path
**Sources:**
- [`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313)
- [`TS3-NodeJS-Library/tests/Client.spec.ts:342-358`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/tests/Client.spec.ts#L342-L358)
### 3.6 Summary Table
| Client | Cache Key Strategy | Dedup Across Servers? | Icon Cache |
|---|---|---|---|
| **Qint** | `<server-uid>/<channel-id>/<path>` | No | Yes (CRC32, mtime freshness) |
| **TS3 Official** | Lazy cache (path-based) | Unknown | Yes |
| **TeaSpeak** | UID-derived avatar ID + browser Cache API | Implicit (same hash = same cache) | Yes (global ImageCache) |
| **TS3AudioBot** | None | N/A | No |
| **TS3 NodeLib** | None | N/A | No |
| **Chanora (decided)** | Content hash (MD5/CRC32), flat `blobs/` in `chanora_cache` crate | **Yes** | Yes |
---
## 4. TeaSpeak Protocol Definitions (Authoritative)
From TeaSpeakLibrary `src/PropertyDefinition.h` — the most complete open-source reference for TS3 protocol property types:
### 4.1 Avatar Properties
```cpp
// Line 200
PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "",
TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}
// "automatically up-to-date for any manager 'in view', this manager got an avatar"
```
### 4.2 Icon Properties
```cpp
// Line 83 — server icon
PropertyDescription{VIRTUALSERVER_ICON_ID, "virtualserver_icon_id", "0",
TYPE_UNSIGNED_NUMBER, FLAG_SERVER_VVSS | FLAG_USER_EDITABLE}
// Line 146 — channel icon
PropertyDescription{CHANNEL_ICON_ID, "channel_icon_id", "0",
TYPE_UNSIGNED_NUMBER, FLAG_CHANNEL_VIEW | FLAG_SS | FLAG_USER_EDITABLE}
// Line 217 — client icon
PropertyDescription{CLIENT_ICON_ID, "client_icon_id", "0",
TYPE_UNSIGNED_NUMBER, FLAG_CLIENT_VIEW | FLAG_CLIENT_VARIABLE}
```
### 4.3 Server Identity
```cpp
// Line 22
PropertyDescription{VIRTUALSERVER_UNIQUE_IDENTIFIER,
"virtualserver_unique_identifier", "",
TYPE_STRING, FLAG_SERVER_VV | FLAG_SNAPSHOT}
```
### 4.4 File Transfer Permissions
```cpp
// From PermissionManager.cpp
PermissionType::i_client_max_avatar_filesize // "Max avatar filesize in bytes"
PermissionType::b_client_avatar_delete_other // "Allow deletion of avatars from other clients"
PermissionType::b_ft_transfer_list // "Retrieve list of running filetransfers"
```
### 4.5 File Transfer Error Codes
```cpp
// From Error.h
channel_no_filetransfer_supported = 0x30C
file_transfer_connection_timeout = 0x80E
file_transfer_complete = 0x811
file_transfer_canceled = 0x812
file_transfer_interrupted = 0x813
file_transfer_server_quota_exceeded = 0x814
file_transfer_client_quota_exceeded = 0x815
file_transfer_reset = 0x816
file_transfer_limit_reached = 0x817
```
---
## 5. Cross-Platform Filesystem Research
### 5.1 Hardlink Support
| Platform | Filesystem | Hardlinks in App-Private Storage? | Gotcha |
|---|---|---|---|
| Android (API 28+) | ext4 / f2fs | **Yes** | Rust uses `libc::link`; not FUSE-mounted; same-filesystem only |
| iOS | APFS | **Yes** (writable sandbox dirs) | App bundle is read-only; avoid hardlinks to bundle assets |
| macOS | APFS | **Yes** | — |
| Linux | ext4 / btrfs / xfs | **Yes** | — |
| Windows | NTFS | **Yes** | Rust uses `CreateHardLinkW` |
**`std::fs::hard_link` gotchas (all platforms):**
- Same filesystem required
- Destination must not exist (returns error)
- Symlink behavior is platform-specific
- All hardlinks share the same inode — modifying one modifies all
- Files must be treated as **immutable** for hardlink safety
**Sources:**
- Rust stdlib: `hard_link` maps to `libc::link` (Unix), `CreateHardLinkW` (Windows) ([Rust source](https://github.com/rust-lang/rust/blob/beae781308e9ddef13074a03faf57ca2fac59a5b/library/std/src/fs.rs#L2898-L2900))
- Android: internal storage uses ext4/f2fs, not FUSE ([Android scoped storage docs](https://source.android.com/docs/core/storage/scoped))
- iOS: APFS supports hardlinks; writable sandbox directories work ([Apple FileSystem basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html))
### 5.2 Rust Cache Libraries
**`cacache`** (MIT licensed, production-ready):
- Content-addressed disk cache
- Automatic dedup, atomic writes, integrity verification
- Exposes `hard_link`, `copy`, and `reflink` paths for retrieval
- On-disk layout: `content-v2/sha512/...`
- Could replace a custom implementation, but adds a dependency
**Sources:**
- [`cacache-rs` README](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/README.md#L39-L60)
- [`cacache-rs` content path](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/path.rs#L6-L19)
- [`cacache-rs` hard_link impl](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/read.rs#L257-L285)
### 5.3 Flutter Cache Patterns
Common Flutter packages use **cache-dir + metadata DB**, not hardlink dedup:
- `flutter_cache_manager`: files in cache dir + `sqflite` metadata
- `super_cache_disk`: file-per-entry (`.dat` + `.meta`) in app cache dir
**Sources:**
- [flutter_cache_manager on pub.dev](https://pub.dev/packages/flutter_cache_manager)
- [super_cache_disk on pub.dev](https://pub.dev/packages/super_cache_disk/versions/1.0.0)
---
## 6. Chanora Codebase Context
### 6.1 Storage Patterns
| Component | Pattern | Location |
|---|---|---|
| Identity storage | Atomic write (temp + `sync_all` + `rename`), mode 0600 on Unix | `chanora_storage/src/lib.rs:446-476` |
| Metadata | Same atomic write pattern | `chanora_storage/src/lib.rs:480-515` |
| Bookmarks | SQLite at `<storage_dir>/chanora.db`, keyed by `host` | `chanora_storage/src/lib.rs:782-928` |
| Storage root | `getApplicationSupportDirectory()` from Flutter | `app_bootstrap.dart:19-24` |
| Bridge init | `rust.initStorage(dir: dir)` | `app_bootstrap.dart:23-25,108-133` |
### 6.2 Existing Avatar Handling
| Component | What | Location |
|---|---|---|
| UID to avatar path | `uid_to_avatar_path()` — base64 decode UID, encode each byte as 2 chars (a-p) | `adapter.rs:1561-1568` |
| Client profile DTO | `avatar_path` field — set when `client.avatar_hash` and `unique_id` non-empty | `dto.rs:135-136`, `adapter.rs:1323-1332` |
| No download | Currently no file download implementation exists | — |
| No icon handling | No icon field/path in protocol DTO or adapter | — |
### 6.3 Server Identity in Chanora
Chanora currently tracks servers by **connection address** (`host:port`), not by server UID:
- Bookmarks: `WHERE host = ?1`
- Recent servers: auto-saved by `cfg.address`
- Prefetch cache: keyed by normalized host
- ServerSnapshot: has `server_name` but no `server_uid`
The `virtualserver_unique_identifier` field is available from tsclientlib's state but is **not extracted** by the adapter.
### 6.4 tsclientlib File Transfer API
| Aspect | Detail |
|---|---|
| Download method | `Connection::download_file()` |
| Stream items | `StreamItem::FileDownload(FileDownloadResult { size, stream })` |
| Failure | `StreamItem::FiletransferFailed(handle, error)` |
| TCP handling | tsclientlib handles TCP connection + ftkey writing automatically |
| Chanora's job | Read `size` bytes from the returned `TcpStream` |
| Async behavior | `StreamItem::FileDownload` fires asynchronously, not inline with the request |
### 6.5 ts-bookkeeping Generated Fields
From the generated parser in `target/debug/build/ts-bookkeeping-*/out/`:
- `virtual_server_id: u64` — numeric, per-virtual-server, may change across restarts
- `virtual_server_uid` — string, the `virtualserver_unique_identifier`
Both are available in the `InInitServer` struct from the init handshake but are not currently plumbed through.
---
## 7. TeaSpeak Server Internals (Authoritative)
Source: TeaSpeak Server at `https://git.did.science/TeaSpeak/Server/Server` (branch `new-groups`, commit `b54c6d4e`).
### 7.1 Avatar ID Derivation — Server Side
The server derives the avatar filename from the **client UID**, not from the avatar content:
```cpp
// DataClient.cpp:242-244
std::string DataClient::getAvatarId() {
return hex::hex(base64::validate(this->getUid()) ? base64::decode(this->getUid()) : this->getUid(), 'a', 'q');
}
```
The same transform produces `client_base64HashClientUID` (shown to other clients):
```cpp
// client.cpp:1113-1114
bulk.put_unchecked("client_base64HashClientUID",
hex::hex(base64::validate(info->client_unique_id) ? base64::decode(info->client_unique_id) : info->client_unique_id, 'a', 'q'));
```
**This matches Chanora's existing `uid_to_avatar_path()` in `adapter.rs:1561-1568`.**
### 7.2 Avatar Upload Path
When a client uploads an avatar, the server stores it as `/avatar_<avatarId>`:
```cpp
// file.cpp:696-702
} else if (cmd["path"].as<std::string>().empty() && cmd["name"].string() == "/avatar") {
...
info.file_path = "/avatar_" + this->getAvatarId();
transfer_response = file::server()->file_transfer().initialize_avatar_transfer(...);
}
```
The avatar file path is identity-based (from UID), not content-based.
### 7.3 `client_flag_avatar` — Who Computes the Hash?
**The CLIENT computes the MD5 and sends it to the server during upload.** The server stores it as a string property (`FLAG_USER_EDITABLE`). The server does NOT compute or verify the hash.
This means `client_flag_avatar` is:
- Set by the uploading client
- Stored verbatim by the server
- Broadcast to other clients as part of the client properties
- A reliable content fingerprint: same avatar bytes → same MD5 → same `client_flag_avatar` on any server
### 7.4 Icon IDs — Server Does NOT Compute CRC32
Icon IDs are **permission values**, not content hashes computed by the server:
```cpp
// ConnectedClient.cpp:186-210 — client icon ID from permissions
auto permission_flags = local_permissions->permission_flags(permission::i_icon_id);
new_icon_id = value.value;
updated_client_properties.emplace_back(property::CLIENT_ICON_ID);
```
```cpp
// channel.cpp:1495-1504 — channel icon ID
if(key == property::CHANNEL_ICON_ID) {
auto icon_id = converter<uint32_t>::from_string_view(value);
channel->permissions()->set_permission(permission::i_icon_id, { ... icon_id ... });
}
```
```cpp
// server.cpp:76-89 — server icon ID
SERVEREDIT_CHK_PROP_CACHED("virtualserver_icon_id", permission::b_virtualserver_modify_icon_id, int64_t)
```
**The CLIENT computes the CRC32 during upload and uses it as the filename `/icon_<crc32>`.** The server stores the file and records the ID as a permission value. No server-side CRC32 or MD5 computation exists.
### 7.5 Per-Server Storage Layout
Avatars and icons are stored **per virtual server** on the server's filesystem:
```cpp
// LocalFileSystem.cpp:39-45
fs::path LocalFileSystem::server_path(const std::shared_ptr<VirtualFileServer> &server) {
return fs::u8path(this->root_path_) / fs::u8path("server_" + std::to_string(server->server_id()));
}
// target_path = this->server_path(server) / "icons" / path;
// target_path = this->server_path(server) / "avatars" / path;
```
```
<server_root>/
server_<sid>/
avatars/
/avatar_<avatarId> ← one per client who uploaded
icons/
/icon_<id> ← one per unique icon
```
### 7.6 File Transfer Protocol (Server Side)
Upload/delete/query routing:
```cpp
// file.cpp:273-341 — delete routing
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... delete_icons(...); }
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... delete_avatars(...); }
```
```cpp
// file.cpp:483-523 — query routing
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... query_icon_info(...); }
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... query_avatar_info(...); }
```
Transfer initialization returns ftkey and metadata:
```cpp
// file.cpp:759-761
result.put_unchecked(0, "ftkey", transfer->transfer_key);
result.put_unchecked(0, "seekpos", transfer->file_offset);
```
```cpp
// file.cpp:887-899
result.put_unchecked(0, "ftkey", transfer->transfer_key);
result.put_unchecked(0, "proto", "1");
result.put_unchecked(0, "size", transfer->expected_file_size);
```
### 7.7 Key Takeaway for Chanora
| Who does what | Avatar | Icon |
|---|---|---|
| **Uploader (client)** computes | MD5 of avatar bytes → sets `client_flag_avatar` | CRC32 of icon bytes → filename `/icon_<crc32>` |
| **Server** does | Stores file as `/avatar_<uid>`, saves property | Stores file as `/icon_<id>`, saves permission |
| **Other clients** receive | `client_flag_avatar` (MD5) as a property update | `icon_id` (CRC32) as a property update |
| **Chanora cache key** | `av_<md5>.dat` — content fingerprint | `ic_<crc32>.dat` — content fingerprint |
The content hash is computed once (by the uploader) and then broadcast as a property. Chanora never needs to hash anything — it just uses the protocol-provided values as cache keys.
---
## 8. Design Implications
### 8.1 The Core Insight
The TS3 protocol provides content hashes as part of normal server-to-client updates:
| Event | Data provided by server | What Chanora gets for free |
|---|---|---|
| Client enters view | `client_flag_avatar` = MD5 of avatar bytes | Content key for blob store |
| Channel update | `channel_icon_id` = CRC32 of icon bytes | Content key for blob store |
| Server update | `virtualserver_icon_id` = CRC32 of icon bytes | Content key for blob store |
No hashing needed on the client side. The protocol is **already content-addressed**.
### 8.2 Recommended Cache Architecture
```
<app_cache_dir>/chanora/
blobs/ ← cacache content store root
content-v2/ ← content-addressed by SHA-512
<sha512-hex>/data ← raw blob bytes
index-v2/ ← key → content mapping
```
Where `<app_cache_dir>` is the platform cache directory (not the support directory used by `chanora_storage`). Chanora's `BlobCache` maps protocol keys (`av_<md5>`, `ic_<crc32>`) to `cacache` string keys. Physical layout is managed by `cacache`.
**Lookup flow:**
1. Server sends `client_flag_avatar = "a1b2c3d4..."` for user X
2. Check: does `blobs/av_a1b2c3d4....dat` exist?
3. Yes → use it, zero downloads (works for ANY server)
4. No → download from `/avatar_<uid_base64>` → save as `blobs/av_a1b2c3d4....dat`
**Same for icons with `ic_<crc32>.dat`.**
### 8.3 Why This Beats Alternatives
| Approach | Dedup | Globally unique key | Needs server UID plumbing | Needs hardlinks | Complexity |
|---|---|---|---|---|---|
| `<server_uid>/<hash>.dat` | No | No (UID not guaranteed unique) | Yes | Optional | Medium |
| `<host>_<port>/<hash>.dat` | No | Yes | No | Optional | Medium |
| `<host>_<port>/<hash>.dat` + hardlinks | Yes | Yes | No | Yes | Medium-High |
| **`blobs/av_<hash>.dat` (flat)** | **Yes** | **Yes (content hash)** | **No** | **No** | **Low** |
### 8.4 Trade-offs
| Pro | Con |
|---|---|
| Zero duplication across all servers | "Clear cache for server X only" requires metadata layer (Phase 3+) |
| No hardlinks needed | Orphan cleanup requires scanning for unreferenced blobs |
| No server UID plumbing needed | Cannot distinguish same-hash-different-content for icons (CRC32 collision) |
| Simplest possible implementation | — |
| Freshness = hash change = different filename (automatic) | — |
| Cross-platform (just file I/O) | — |
### 8.5 Phased Implementation
| Phase | What | Delivers |
|---|---|---|
| 1 | Protocol download (raw bytes via adapter, no cache) | Working download pipeline |
| 2 | Bridge + Flutter display (`Image.memory()`) | Visible avatars in UI |
| 3 | `chanora_cache` crate: cacache-backed blob cache, separate crate, cache dir, mtime eviction | Zero re-downloads, zero duplication |
| 4 | Session orchestration (coalescing, rate limiting, negative cache) | Anti-flood, robustness |
| 5 | Cache management (clear all, orphan cleanup, optional per-server metadata) | User control |
---
## 9. Resolved Questions
### Q1: Icon CRC32 Collisions — Accept with Size Guard
**Risk assessment:** CRC32 produces a 32-bit hash. For N unique icons, the Birthday paradox gives collision probability ≈ N² / (2 × 2³²).
| Icons (N) | Collision probability |
|---|---|
| 100 | ~0.0001% (negligible) |
| 1,000 | ~0.01% (negligible) |
| 10,000 | ~1.2% (marginal) |
| 65,536 | ~50% (likely) |
A single user typically encounters fewer than 1,000 unique icons across all servers. The practical collision risk is negligible.
**What happens on collision:** Wrong icon displayed for a channel/client/server. This is a visual glitch, not a security issue. The icon will appear incorrect until the cache is cleared.
**Existing practice:** Qint explicitly notes CRC32 collisions (`filecache.rs:4`) but does NOT guard against them — they only refresh icons by mtime. No other TS3 client guards against CRC32 collisions.
**Recommendation:** Accept CRC32 as the cache key. Add a lightweight **file size guard**: when downloading an icon, if `ic_<crc32>.dat` already exists but has a different size than the `ftinitdownload` response reported, re-download. File size is available from the protocol (`msg.size` in `InFileDownloadPart`). This catches most collisions (different content = different size with high probability) without computing a secondary hash.
**Decision:** CRC32 + file size guard. No SHA256 overhead needed.
---
### Q2: `client_myteamspeak_avatar` — Defer Indefinitely
**What it is:** A string property (`Option<String>` in ts-bookkeeping) broadcast alongside `client_flag_avatar`. It represents a myTeamSpeak cross-server avatar — a user linked to a myTeamSpeak account can set a global avatar that follows them across all servers.
**Current state in Chanora's dependency chain:**
- ts-bookkeeping exposes it: `InInitServer` has `my_team_speak_avatar: Option<String>`
- TeaSpeakLibrary tracks `client_myteamspeak_id` but not the avatar
- tsclientlib exposes it as a property on client state
**Value for Chanora:**
- myTeamSpeak is a TeamSpeak-specific cloud service (account sync, cross-server features)
- Chanora is an independent client — no myTeamSpeak account integration is planned
- The property may contain a URL or identifier that requires myTeamSpeak API access to resolve
- Without myTeamSpeak integration, the avatar cannot be fetched
**Recommendation:** Defer indefinitely. If Chanora ever integrates myTeamSpeak accounts, this can be handled as a separate avatar source (URL-based HTTP download) alongside the existing protocol-based avatar download. The cache architecture supports this — just add a different blob prefix (e.g., `mt_<hash>.dat`).
**Decision:** Out of scope for MVP and foreseeable roadmap.
---
### Q3: Cache Backing Store — `cacache` Wrapper in Separate `chanora_cache` Crate
**Decision:** Use `cacache` as the backing store inside `chanora_cache`. Not a custom flat-file implementation.
**Why cacache won over custom:**
1. **Crash safety is production-tested.** `cacache` handles partial writes, power loss, crash mid-write. A custom implementation would need to get `sync_all` + atomic rename right — one bug = corrupted cache. Even though cache data is disposable (reconstructible from server), `cacache` eliminates this entire class of bugs.
2. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. The hard parts (atomic writes, integrity, content dedup) are owned by `cacache`, tested by the npm ecosystem.
3. **Integrity verification on every read.** SSRI verification detects corruption, bit rot, partial writes automatically. A custom impl would need to add this separately or accept silent corruption.
4. **Content dedup by SHA-512.** Same avatar on two servers = stored once automatically. The protocol's MD5/CRC32 keys map to `cacache` string keys; content dedup happens at the SHA-512 layer underneath.
**What about the downsides:**
| Concern | Assessment |
|---|---|
| ~6 transitive deps | `sha2` already in tree via `chacha20poly1305`. `serde_json`, `tempfile`, `digest` are lightweight. Acceptable for the safety benefit. |
| SHA-512 overhead on every write/read | For <100KB avatars, SHA-512 takes ~0.1ms. Negligible. |
| Opaque on-disk format | `cacache` provides `ls()` API for enumeration and inspection. Not as simple as `ls blobs/` but adequate. |
| `cacache` has no built-in LRU eviction | We write a custom eviction pass using `cacache::ls()` + timestamp sort. ~20 lines. Same complexity as custom impl's eviction. |
**Separate crate rationale:**
- `chanora_cache` is separate from `chanora_storage` because cache data has different durability semantics (disposable vs persistent), different backup semantics (excluded vs included), and different directory placement (cache dir vs support dir).
- `chanora_cache` lives in the platform's cache directory (`getApplicationCacheDirectory()`). `chanora_storage` lives in the support directory (`getApplicationSupportDirectory()`).
- Bridge init is separate: `init_cache(cache_dir)` vs `init_storage(support_dir)`.
**API design:**
```rust
pub struct BlobCache { cache_dir: PathBuf, max_bytes: u64 }
impl BlobCache {
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError>;
pub async fn put(&self, prefix: &str, key: &str, data: &[u8]) -> Result<(), BlobCacheError>;
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError>;
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError>;
pub async fn clear(&self) -> Result<(), BlobCacheError>;
pub async fn total_size(&self) -> Result<u64, BlobCacheError>;
pub async fn evict(&self) -> Result<(), BlobCacheError>;
}
```
All methods are async (cacache is async-native). Key validation at API boundary (`av_` = 32 hex chars, `ic_` = decimal digits).
---
### Q4: Per-Blob Metadata — No Metadata Sidecars (Resolved)
**Original options:**
| Approach | Pros | Cons |
|---|---|---|
| SQLite (chanora.db) | ACID, queryable, already in use | Schema migration, couples cache to bookmark DB |
| JSON sidecar files | Simple, self-contained, easy to debug | Write amplification (2 files per blob), concurrent write risk |
| In-memory only | Simplest | Lost on restart, can't do orphan cleanup offline |
| **No metadata (mtime-based)** | **Simplest, zero write amplification, 1 file per blob** | **No per-blob metadata beyond mtime** |
**Why no metadata is sufficient:**
1. **Content is immutable.** A given hash (MD5 or CRC32) always maps to the same bytes. There is no "stale content" problem — if the hash changes, it's a new file with a new name. No invalidation needed.
2. **mtime = insertion time.** Since content is never modified after write, the filesystem mtime equals the time the blob was cached. This is sufficient for "delete oldest files first" eviction.
3. **Write amplification avoided.** One file per blob (just the data) instead of two (data + JSON sidecar). For a cache that may hold thousands of small files, this matters.
4. **Eviction is simple.** `walk dir → stat → sort by mtime → delete oldest`. No JSON parsing, no schema, no migration.
5. **Per-server metadata deferred.** "Clear cache for server X only" and orphan cleanup are post-MVP features. If needed, a refs-layer can be added later without changing the blob layout.
**Oracle consultation:** Oracle recommended this approach explicitly — no metadata files, mtime-based eviction, separate crate. The immutability guarantee makes metadata redundant.
**Decision:** No metadata sidecars. One file per blob. Mtime-based eviction. Per-server metadata deferred to post-MVP.
---
### Q5: File Transfer Address Failover — Not Needed
**What the protocol provides:**
The TeaSpeak client's `InitializedTransferProperties` returns `addresses[]` — an array of `{serverAddress, serverPort}`. The official TS3 client can try multiple addresses for failover.
**What tsclientlib provides:**
```rust
// tsclientlib/src/lib.rs:1373-1375
let ip = msg.ip.unwrap_or_else(|| self.client.address.ip());
let addr = SocketAddr::new(ip, msg.port);
TcpStream::connect(&addr).await
```
tsclientlib's `InFileDownloadPart` has `ip: Option<IpAddr>`**single IP only**, not an array. If the server provides an IP, it uses that. Otherwise, it falls back to the connection address. **No multi-address failover.**
**What ts-bookkeeping parses:**
```rust
pub struct InFileDownloadPart {
pub client_filetransfer_id: u16,
pub server_filetransfer_id: u16,
pub filetransfer_key: String,
pub port: u16,
pub size: u64,
pub protocol: u8,
pub ip: Option<IpAddr>, // ← single optional IP
}
```
**The server's `notifystartdownload` response** sends `ip` as an optional single value, not an array. The TeaSpeak client's `addresses[]` is a higher-level abstraction (likely the client's own fallback logic), not a protocol feature.
**Recommendation:** Chanora follows tsclientlib's existing behavior — use `msg.ip` or fallback to connection address. No custom failover logic needed. If the TCP connection fails, the download fails and retries follow the exponential backoff strategy from the design doc.
**Decision:** Single address (from tsclientlib). No failover needed.
+7 -3
View File
@@ -42,7 +42,7 @@ Chanora is a Flutter application with a Rust core. Flutter owns the user-facing
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state, bridge-facing event DTOs | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
@@ -68,6 +68,8 @@ Flutter UI/widgets/services
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
Current Core locality note: the public Core Interface remains available through `chanora_core::*` re-exports, while branch `simplify-project-review` has started moving internal Core responsibilities into focused Modules (`events.rs`, `network_diagnostics.rs`). This is an internal maintainability split, not a public Interface change.
## 6. Runtime Flow Architecture
### 6.1 Connect Flow
@@ -121,7 +123,7 @@ Runtime event or error
| Bridge command DTOs | Flutter generated API | `chanora_bridge`, Rust core | Stable typed DTOs; no raw protocol-library types cross to Flutter |
| Bridge event DTOs | Rust core / bridge | Flutter services/widgets | User-safe errors and capability fields are explicit |
| Protocol DTOs | `chanora_protocol` | Rust core, state sync | Protocol adapter isolates `tsclientlib` |
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; VAD remains disabled/deferred |
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; Windows/Linux desktop VAD-backed `VoiceActivity` is enabled only where runtime evidence exists, with unsupported platforms disabled/deferred |
| Storage records | Storage crate | Rust core / Flutter UI via bridge | Secrets stay behind secure-storage abstraction |
| Diagnostic bundles | Diagnostics crate | Flutter diagnostics UI | Redaction runs before export or display |
| Platform capability records | Platform adapters/audio/PTT backends | UI and release record | UI/release wording must not over-claim capability |
@@ -157,7 +159,7 @@ Runtime event or error
| Secure storage abstraction | Platform storage details do not leak into UI or unrelated crates |
| Advisory audio benchmarks | Performance regressions are surfaced without making CI a hard release gate at this stage |
| PTT capability levels | Platform PTT support is represented as capability data and must match release wording |
| VoiceActivity deferral | `VoiceActivity` remains reserved/disabled until a later baseline allocates implementation |
| VoiceActivity platform scope | Windows/Linux desktop `VoiceActivity` is implemented through the capture VAD path; unsupported platforms remain disabled/deferred until backend allocation and runtime verification exist |
| No automatic diagnostic upload in MVP | Diagnostics are local and user-initiated unless future approved requirements change policy |
## 11. Verification Handoff
@@ -180,6 +182,8 @@ This SAD derives only from `docs/srs.md`. The broad SRS group-to-component alloc
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
| Android runtime verification is not automatic in local reviews | Android permission/audio/lifecycle regressions can pass Rust-only tests | Require `adb devices -l` with a connected device/emulator and Android smoke evidence before claiming Android runtime success |
| Protocol voice packet re-export is an intentional exception to full protocol isolation | Future changes may accidentally widen the protocol/audio Seam | Document and keep the voice wire exception narrow, or move packet construction fully into `chanora_protocol` |
## 14. DV Conclusion
+4 -2
View File
@@ -21,7 +21,7 @@ This Software Detailed Design defines the module-level design details needed for
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `ptt.rs` | Connection orchestration, reconnect, PTT state, storage coordination | Rust core |
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | Connection orchestration, reconnect, bridge-facing event DTOs, network diagnostics, PTT state, storage coordination | Rust core |
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
@@ -50,6 +50,7 @@ Design rules:
|---|---|
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
| Core internal Modules | `lib.rs` remains the public Interface and orchestration entry point; `events.rs` owns public event/bridge-facing DTOs re-exported by `lib.rs`; `network_diagnostics.rs` owns private connect/loss counters and the last-loss ring buffer |
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
@@ -62,7 +63,7 @@ Design rules:
| Capture/playback | Platform-specific units handle Android, iOS, desktop/fallback paths behind Rust audio abstractions |
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and reserved `VoiceActivity`; `VoiceActivity` has no active MVP implementation |
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and `VoiceActivity`; `VoiceActivity` is active for Windows/Linux desktop capture when VAD is configured, while mobile, macOS, and unverified-platform enablement remain deferred |
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
@@ -153,6 +154,7 @@ Design rules:
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
| Android runtime smoke is blocked when no device/emulator is attached | Android permission/audio/lifecycle paths cannot be claimed from Rust tests alone | Require `adb devices -l` and Android smoke evidence before closing Android verification claims |
## 13. DV Conclusion
@@ -11,7 +11,7 @@
| Android Keystore-backed DEK deferred | Secure-storage claim limited | Storage design carries fallback limitation | Platform audit required | Waiver required |
| Full reducer tests incomplete | State verification incomplete | State design remains valid but evidence partial | SWE.4/SWE.5 partial | Blocks full state-sync claim |
| Desktop/iOS artifacts not release-ready | Platform packaging requirements partial | Release design remains source-build/unsigned | SYS.4 evidence partial | Public binary release No-Go |
| VAD deferred | VoiceActivity not active | UI must show disabled/coming-soon | No VAD pass claim | No VAD marketing claim |
| VAD platform-scoped | VoiceActivity active only for verified Windows/Linux desktop paths | UI must show disabled/unavailable on unsupported platforms | VAD pass claim must name verified platform/runtime evidence | No broad VAD marketing claim without platform scope |
## 2. Conclusion
+1
View File
@@ -26,6 +26,7 @@ This index lists the documents required for DV review and identifies their curre
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
| Governance | `docs/governance/maintainability-review-2026-06-08.md` | Working-branch maintainability and fail-safe review |
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
@@ -0,0 +1,99 @@
# Chanora Maintainability Review — 2026-06-08
**Document status:** Working-branch review record
**Branch:** `simplify-project-review`
**Scope:** Project-wide simplification, fail-safe, and verification review
## 1. Purpose
This record captures the current maintainability review so implementation, verification, and release documents do not drift behind the code. It focuses on unnecessary Modules, shallow Interfaces, duplicate Implementations, built-in replacement opportunities, and fail-safe gaps that need explicit evidence before release claims.
## 2. Changes Already Applied on the Branch
| Area | Files | Maintainability result |
|---|---|---|
| Core event DTO locality | `core/chanora_core/src/events.rs`, `core/chanora_core/src/lib.rs` | Public Core event DTOs moved out of the oversized Core integration Module while preserving the public `chanora_core::*` Interface through re-exports. |
| Core network diagnostics locality | `core/chanora_core/src/network_diagnostics.rs`, `core/chanora_core/src/lib.rs` | Private network diagnostic ring-buffer state and its regression test now live next to the Implementation they protect. |
| Bounded queues | `core/chanora_core/src/network_diagnostics.rs`, `crates/chanora_diagnostics/src/lib.rs` | Replaced `Vec + remove(0)` queue behaviour with `VecDeque`, reducing custom queue code and avoiding O(n) front removal. |
| PTT backend errors | `crates/chanora_audio/src/ptt_backends/mod.rs` | Replaced manual `Display` / `Error` Implementation with existing `thiserror::Error`; regression test keeps user-facing strings stable. |
| Render downmix | `crates/chanora_audio/src/voice_render.rs`, `crates/chanora_audio/src/ios_raw_unit.rs` | Removed duplicate mono-i16 downmix loop by using the interleaved helper with one output channel. |
| State reducer | `crates/chanora_state/src/lib.rs` | Reused the main snapshot reducer for reconnect snapshots instead of duplicating normalization and delta construction. |
| Workspace metadata | `crates/chanora_resolver/Cargo.toml`, `Cargo.lock` | Resolver inherits workspace package metadata, improving release metadata Locality. |
| Flutter voice fail-safes | `apps/chanora_flutter/lib/main.dart`, `apps/chanora_flutter/lib/widgets/voice_compact.dart`, `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart` | Commit `d835394` preserves independent mute owners, releases touch PTT on disposal while held, and catches iOS audio-session `MissingPluginException` / activation failures so they do not become unhandled async errors. |
| Rust realtime callback hardening | `crates/chanora_audio/src/android_voice_unit.rs`, `crates/chanora_audio/src/ios_raw_unit.rs`, `crates/chanora_audio/src/engine.rs` | Commit `8606eb4` hardens Android/iOS realtime callback paths. The current branch also migrates the Android-only JNI paths to `jni-rs` 0.22 so the supported ARM64 Android debug build compiles. Full lock-free audio-handler/config/debug-recorder redesign remains follow-up work. |
## 3. Remaining Simplification Opportunities
| Recommendation | Candidate files | Strength | Notes |
|---|---|---|---|
| Continue splitting Core internals by responsibility | `core/chanora_core/src/lib.rs` | Strong | Next slices should be reconnect/session, voice projection, storage helpers, and diagnostics export. Keep public re-exports stable. |
| Make Bridge depend on Core rather than Audio where possible | `crates/chanora_bridge/Cargo.toml`, `crates/chanora_bridge/src/api.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | The Bridge currently has a direct audio edge. Apply the deletion test before removing it. |
| Decide whether prefetch deserves a crate-level Seam | `crates/chanora_prefetch/src/lib.rs`, `crates/chanora_resolver/src/lib.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | Prefetch is a small TTL cache and fire-and-forget resolver Adapter. Merge into resolver if it is resolver policy; merge into Core if it is app orchestration policy. |
| Consolidate protocol/core/bridge event catalogues | `crates/chanora_protocol/src/dto.rs`, `core/chanora_core/src/events.rs`, `crates/chanora_bridge/src/api.rs` | Worth exploring | Protocol-owned deltas and Core-owned lifecycle events are currently mirrored through multiple DTO layers. |
| Reduce bridge DTO mirror boilerplate | `crates/chanora_bridge/src/api.rs` | Worth exploring | Verify Flutter Rust Bridge support before deleting mirrors. If mirrors remain required, centralize conversion patterns and keep field order aligned with Core DTOs. |
| Clarify protocol voice packet exception | `crates/chanora_protocol/src/lib.rs`, `crates/chanora_audio/Cargo.toml` | Worth exploring | The protocol crate documents `tsclientlib` isolation but deliberately re-exports voice packet types for audio. Document this as an explicit voice wire Seam or move packet construction fully into protocol. |
| Remove shallow audio helpers only after public API check | `crates/chanora_audio/src/processor/noop.rs`, `crates/chanora_audio/src/processor/platform.rs`, `crates/chanora_audio/src/frame.rs` | Speculative | These Modules are shallow, but deletion must wait until external/public API expectations are checked. |
| Redesign remaining audio shared state outside realtime callbacks | `crates/chanora_audio/src/engine.rs`, platform voice units, debug recorder/config paths | Strong follow-up | The focused callback hardening is complete, but a full lock-free `AudioHandler` / config / debug-recorder redesign should be planned separately and verified on device. |
| Review protocol/core disconnect and control-plane bounds | `core/chanora_core/src/lib.rs`, `crates/chanora_protocol/src/adapter.rs` | Strong follow-up | Unless closed by a later code slice, sustained voice traffic and broken transport should be reviewed for bounded control request and disconnect progress. |
## 4. Fail-Safe Gaps That Need Evidence
| Gap | Risk | Required evidence before release claim |
|---|---|---|
| Android Keystore-backed DEK remains deferred | Android identity/bookmark encryption has weaker fail-safe properties than final target secure-storage design. | Android secure-storage audit or waiver; explicit release-readiness limitation. |
| Android permission/audio lifecycle needs deeper route exercise | Build/install/launch smoke now passes on the emulator, but full permission-flow and audio-route lifecycle behavior still need an interactive scenario or device test before release. | Device/emulator scenario covering permission request/denial/grant, voice controls, foreground service, audio focus, and route/SCO transitions. |
| iOS device runtime verification not executed in this review | The iOS audio-session error path is hardened, but VoiceProcessingIO/session ordering and runtime audio behavior still need device evidence. | iOS device or simulator build/run plus audio-session smoke evidence before iOS runtime success is claimed. |
| VAD / VoiceActivity wording drift | VAD assets, tests, and Windows/Linux desktop runtime wiring exist, but product-enabled `VoiceActivity` must remain platform-scoped per DEC-030. | Release, README, and verification wording must distinguish verified desktop behavior from unsupported mobile/macOS/unverified-platform behavior. |
| Protocol voice packet re-export is an intentional exception | Future maintainers may assume complete protocol isolation and accidentally widen the Seam. | Architecture note in SAD/SDD or a decision-register entry. |
| Bridge DTO mirror drift | Field additions can be missed across Core, Bridge, and Dart generated DTOs. | Bridge generation check plus Flutter analyze/test after bridge DTO changes. |
| Full live reducer integration remains separate from reducer unit coverage | State reducer tests are strong, but runtime UI still has snapshot/probe paths. | SWE.5 integration run proving live protocol events fold through the intended state path, or explicit P1 deferral. |
| Event replay tooling remains absent | Replay-based diagnosis and regression reproduction are limited. | Event replay tool implementation or waiver. |
## 5. Verification Policy for Future Code Changes
| Change type | Required verification |
|---|---|
| Rust-only change | `cargo fmt --all`, `cargo check --workspace`, `cargo test --workspace` |
| Bridge DTO/API change | Rust verification plus bridge generation check, `flutter analyze`, and `flutter test --exclude-tags e2e` in `apps/chanora_flutter` |
| Android platform/audio/permission change | Rust/Flutter verification plus NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test |
| Documentation-only change | Read affected docs and ensure cross-links/document index stay current; code tests are not required unless docs describe a code change just made |
## 6. Android ADB Status for This Review
`adb devices -l` now reports an authorized emulator target:
```text
emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
```
Android default debug build still fails because SDD-118 excludes `armeabi-v7a`; use a supported ABI target. ARM64 debug build/install/launch smoke evidence from 2026-06-08:
```text
flutter build apk --debug --target-platform android-arm64
✓ Built build/app/outputs/flutter-apk/app-debug.apk
adb -s emulator-5554 install -r apps/chanora_flutter/build/app/outputs/flutter-apk/app-debug.apk
Success
adb -s emulator-5554 shell am start -W -n app.chanora.chanora_flutter/.MainActivity
Status: ok
LaunchState: COLD
Activity: app.chanora.chanora_flutter/.MainActivity
TotalTime: 6514
adb -s emulator-5554 shell pidof app.chanora.chanora_flutter
7287
```
`dumpsys window app.chanora.chanora_flutter` showed `MainActivity` visible with `isReadyForDisplay()=true`, and `dumpsys activity top` showed `app.chanora.chanora_flutter/.MainActivity` resumed with window focus. This is build/install/launch smoke evidence only; permission-flow success and audio-lifecycle success are not claimed by this review.
## 7. Release and Documentation Alignment Notes
- Android minimum runtime baseline is API 28 (Android 9.0) per SysRS-288, SRS-187, DEC-004, and the Gradle `minSdk = 28` configuration. Documents must not revive the older API 24 baseline.
- Flutter app version/build is `0.3.0+100` in `apps/chanora_flutter/pubspec.yaml`. Rust workspace package version remains `0.2.0-beta.1`. Release documents must distinguish these values instead of treating them as one candidate version.
- The v0.3.0 changelog entry may mention Windows/Linux desktop VAD-backed `VoiceActivity` only with matching runtime evidence; mobile, macOS, and unverified-platform `VoiceActivity` remain disabled/unavailable until DEC-030 is superseded and runtime verification exists.
- README wording must describe the existing Flutter/Rust workspace and app scaffold, not a future scaffold that has not been created.
## 8. Git Policy
No commit is created automatically. Commit only on explicit user demand, after reviewing `git status`, `git diff`, and recent log output.
+3 -1
View File
@@ -11,12 +11,14 @@ This register records product and engineering decisions referenced by the DV doc
| Decision | State | DV impact |
|---|---|---|
| DEC-004 Android minimum runtime API 28 | Accepted by requirements baseline | Android release, verification, and README wording must use API 28 rather than the earlier API 24 recommendation |
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
| DEC-030 VAD deferral | Accepted as deferral | `VoiceActivity` remains disabled/coming-soon |
| DEC-030 VAD platform scope | Partially superseded by desktop enablement | Windows/Linux desktop `VoiceActivity` is enabled through the audio capture VAD path and must be claimed only with matching runtime evidence; mobile, macOS, and unverified-platform `VoiceActivity` remain disabled/deferred until a later baseline enables and verifies them |
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
| DEC-034 Android runtime verification gate | Active tracking | Android target compilation, install, and runtime smoke are blocked locally until `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized target; release docs must not claim Android runtime success |
## 3. DV Rule
+14 -12
View File
@@ -1,8 +1,9 @@
# Chanora Implementation Status — 2026-05-28
**Workspace version:** `v0.2.0-beta.1`
**CHANGELOG latest:** `v1.0.0-rc.1`
**Build status:** All 9 crates compile cleanly.
**Flutter app version/build:** `0.3.0+100`
**CHANGELOG latest:** `v0.3.0`
**Build status:** Host Rust workspace evidence shows all 9 crates compile cleanly. This does not claim Android target success; Android target compile/install/smoke evidence remains blocked locally as noted below.
---
@@ -18,9 +19,9 @@
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
| Protocol adapter | `chanora_protocol``tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
| Connection lifecycle | `chanora_core` (2682-line `lib.rs`) — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect |
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; branch `simplify-project-review` has started splitting the previous large `lib.rs` into focused internal Modules (`events.rs`, `network_diagnostics.rs`) while preserving public re-exports |
| State sync reducer unit | `chanora_state``ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, VAD |
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and Windows/Linux desktop `VoiceActivity` through the capture VAD path. Mobile, macOS, and unverified-platform `VoiceActivity` remain deferred per DEC-030. |
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0L3); missed-key-up watchdog |
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
| Storage (non-secret) | `chanora_storage``BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
@@ -45,7 +46,7 @@
| Link trust | `link_trust_service.dart` |
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
| CI | GitHub Actions on every push |
| Workspace compiles | All 9 crates build cleanly |
| Workspace compiles | Host Rust workspace evidence shows all 9 crates build cleanly; Android target compilation remains blocked locally as noted below. |
### Partial / Scaffold Only
@@ -53,18 +54,19 @@
|---|---|
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
| macOS build | Not in `v1.0.0-rc.1` release artifacts (source-buildable only per `staged-release-plan.md`). |
| Windows build | Same — source-buildable, not in rc.1 release artifacts. |
| iOS build | Same — source-buildable, not in rc.1 release artifacts. |
| Mobile/macOS VoiceActivity | `assets/models/silero_vad.onnx` is bundled and used by the desktop VAD path where runtime evidence supports it; mobile, macOS, and unverified-platform `TransmitMode::VoiceActivity` remain disabled/deferred until a later baseline supplies backend enablement and verification evidence. |
| macOS build | Source-buildable only; no public release artifact is approved. |
| Windows build | Source-buildable only; no public release artifact is approved. |
| iOS build | Source-buildable/unsigned validation only; no TestFlight/App Store release artifact is approved. |
### Not Done (P0 blockers remaining)
| Item | Status |
|---|---|
| DEC-012 legal/trademark/OSS review | Explicitly open`v1.0.0-rc.1` is the candidate awaiting sign-off. Public release is blocked. |
| DEC-012 legal/trademark/OSS review | Explicitly open. Public release is blocked. |
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`; release readiness still requires device audio validation and candidate evidence attachment. |
| Android target compile/install/smoke evidence | Blocked locally until the Android NDK compiler `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized device or emulator. |
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). Release readiness still requires device audio validation and candidate evidence attachment. |
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
---
@@ -92,7 +94,7 @@
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
| Network diagnostics (SRS-100) | Not found in diagnostics export |
| Network diagnostics (SRS-100) | Core tracks connect/disconnect counts and last-loss reasons in `network_diagnostics.rs`; export/integration evidence still needs release-candidate attachment |
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
| Keyboard focus traversal (SRS-160) | Not confirmed |
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
+2 -1
View File
@@ -18,8 +18,9 @@ A waiver records a known gap that reviewers may accept for a limited decision sc
| DV-WVR-004 | Standalone event replay and runtime reducer-integration evidence not yet complete | `docs/implementation-status-2026-05-28.md`, local `cargo test -p chanora_state --locked` evidence | Reducer unit coverage supports state synchronization, but file-based replay evidence and live-event integration evidence remain open | DV documentation pass and internal validation | Event replay tooling implemented or requirement reprioritized; runtime reducer integration evidence attached |
| DV-WVR-005 | Event replay tool not found | `docs/implementation-status-2026-05-28.md` | Limits P1 state verification hooks SRS-061/SRS-098 | Accepted as P1 deferral | Event replay tooling implemented or requirement reprioritized |
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is platform-scoped | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability beyond verified Windows/Linux desktop paths | DV may pass if VoiceActivity claims are limited to verified desktop evidence and unsupported platforms remain disabled/unavailable | Mobile/macOS implementation allocated in a later baseline or asset/wording reconciled |
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
| DV-WVR-009 | Android target compile and runtime smoke blocked locally | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/release-readiness-go-nogo-record.md` | Blocks Android runtime, permission-flow, and audio-lifecycle success claims | Documentation review only; internal validation must keep Android limitation stated | Android NDK compiler `aarch64-linux-android-clang` is available, `adb devices -l` shows an authorized target, and Android build/install/smoke evidence is attached |
## 3. Waiver Review Rules
@@ -2,7 +2,7 @@
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
**Candidate:** `v1.0.0-rc.1` evidence over workspace version `0.2.0-beta.1`
**Candidate:** documentation/DV evidence over Rust workspace version `0.2.0-beta.1`; Flutter app version/build `0.3.0+100`
**Decision:** No-Go for public/store release; Conditional Go only for documentation review and continued internal DV validation
## 1. Decision Summary
@@ -23,10 +23,11 @@ The phrase `Conditional Go` in this record is restricted to document-baseline re
| Field | Value |
|---|---|
| Workspace version | `0.2.0-beta.1` |
| CHANGELOG latest candidate | `v1.0.0-rc.1` |
| Build number | `76` from `apps/chanora_flutter/pubspec.yaml` |
| Flutter app version/build | `0.3.0+100` from `apps/chanora_flutter/pubspec.yaml` |
| CHANGELOG latest candidate | `v0.3.0` baseline entry; public release version not reached |
| Build number | `100` from `apps/chanora_flutter/pubspec.yaml` |
| Commit SHA | To be recorded from the candidate build job before release approval |
| Git tag | To be recorded if `v1.0.0-rc.1` is tagged for release validation |
| Git tag | To be recorded if a candidate is tagged for release validation |
| Artifact hashes | Not recorded in current workspace; required before release approval |
| Release owner | Product / Release Operations |
| Verification owner | Software QA with System Engineering support |
@@ -54,6 +55,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
| Privacy policy baseline | Baseline candidate | Requires owner/legal review before public/store release |
| Security/privacy evidence | Partial | Blocks strong secure-storage and diagnostic claims until audits attach evidence |
| Android secure-storage DEK | Deferred to v1.1 | Requires waiver for internal testing; limits release claim |
| Android target compile/install/smoke | Blocked locally | Missing Android NDK compiler `aarch64-linux-android-clang` and no authorized ADB target block Android runtime claims |
| iOS release build/signing | Unsigned verification only | Blocks TestFlight/App Store release |
| macOS signing/notarization | Not complete | Blocks macOS public binary release |
| Windows/Linux packaging | Source-buildable only for candidate | Blocks packaged public desktop release claims |
@@ -70,6 +72,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
| iOS unsigned build | CI defined | Attach latest passing candidate run; add signing evidence before release |
| Compatible-server demo | Evidence not attached in this record | Run and attach demo notes/logs |
| Audio send/receive and processing demo | Evidence not attached in this record | Run and attach platform evidence |
| Android target build/install/smoke | Blocked locally | Install/fix Android NDK compiler, connect/authorize a device or emulator, then attach build/install/smoke evidence |
| Diagnostics redaction/export demo | Evidence not attached in this record | Run and attach export review |
| Platform secure-storage audit | Partial | Attach per-platform audit or waiver |
| PTT capability evidence | Partial | Attach per-platform `PttCapabilityLevel` and backend record |
@@ -78,7 +81,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
| Platform | Current readiness | Release decision |
|---|---|---|
| Android | Core platform implementation present; Android Keystore-backed DEK deferred | Conditional internal validation only |
| Android | Core platform implementation present; Android Keystore-backed DEK deferred; local target compile/install/smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | Conditional internal validation only after Android build/install/smoke evidence or explicit waiver |
| iOS | Unsigned build path present; signing and store pipeline incomplete | No-Go for store release |
| Windows | Source-buildable; smoke procedure exists | No-Go for packaged release until smoke/signing evidence exists |
| macOS | Source-buildable; public artifact not in candidate | No-Go for packaged release until signing/notarization evidence exists |
+193
View File
@@ -49,6 +49,11 @@ terms.
| `flutter` | 0.0.0 | sdk | yes |
| `flutter_foreground_task` | 9.2.2 | hosted | yes |
| `flutter_lints` | 6.0.0 | hosted | yes |
| `flutter_local_notifications` | 22.0.0 | hosted | yes |
| `flutter_local_notifications_linux` | 8.0.1 | hosted | yes |
| `flutter_local_notifications_platform_interface` | 12.0.0 | hosted | yes |
| `flutter_local_notifications_web` | 1.0.0 | hosted | yes |
| `flutter_local_notifications_windows` | 3.1.0 | hosted | yes |
| `flutter_localizations` | 0.0.0 | sdk | yes |
| `flutter_rust_bridge` | 2.12.0 | hosted | yes |
| `flutter_test` | 0.0.0 | sdk | yes |
@@ -117,6 +122,7 @@ terms.
| `string_scanner` | 1.4.1 | hosted | yes |
| `term_glyph` | 1.2.2 | hosted | yes |
| `test_api` | 0.7.11 | hosted | yes |
| `timezone` | 0.11.0 | hosted | yes |
| `typed_data` | 1.4.0 | hosted | yes |
| `url_launcher` | 6.3.2 | hosted | yes |
| `url_launcher_android` | 6.3.30 | hosted | yes |
@@ -1869,6 +1875,166 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications 22.0.0
```
Copyright 2018 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_linux 8.0.1
```
Copyright 2018 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_platform_interface 12.0.0
```
Copyright 2020 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_web 1.0.0
```
Copyright 2020 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_windows 3.1.0
```
Copyright 2024 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_localizations 0.0.0
```
@@ -4673,6 +4839,33 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### timezone 0.11.0
```
Copyright (c) 2014, timezone project authors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### typed_data 1.4.0
```
+2 -1
View File
@@ -291,7 +291,7 @@ those terms.
| chanora_diagnostics | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_prefetch | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_protocol | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_resolver | 0.1.0 | `Apache License 2.0` | — |
| chanora_resolver | 0.2.0-beta.1 | `Apache License 2.0` | — |
| chanora_state | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_storage | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| alsa | 0.11.0 | `Apache License 2.0` | <https://github.com/diwic/alsa-rs> |
@@ -10967,3 +10967,4 @@ cargo about generate --output-file docs/security/license-inventory.html about.hb
This artefact supports the DEC-012 legal review handoff at
`docs/governance/legal-review-readiness.md`.
+3 -3
View File
@@ -1317,7 +1317,7 @@ Therefore:
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
- Owner: Software Team
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions.
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions. The session shall be configured for VoIP (`.playAndRecord` + `.voiceChat` + `.mixWithOthers`) only while a voice channel is active, and shall return to a non-disruptive idle state (`.ambient`, inactive, with `.notifyOthersOnDeactivation`) at all other times so that other apps' audio (music, podcasts, navigation) is preserved when the user opens Chanora to read text chat.
- Type: Platform / iOS
- Stage: P0 / MVP
@@ -2597,7 +2597,7 @@ This section extends the ASPICE SWE.1 Software Requirements Specification. The s
- Source SysDes: SysDes-150
- Verification method: Integration Test, UI Review
**SRS-205**: The software shall represent the user's voice transmit mode as a `TransmitMode` enum with variants `Ptt`, `Continuous`, and `VoiceActivity` (the last reserved with no v1 implementation per DEC-030). The setting shall be persisted per identity via the identity store. The default value for a fresh install shall be `Ptt`. The UI shall render `VoiceActivity` as a disabled "coming soon" option until an implementation is allocated in a later baseline.
**SRS-205**: The software shall represent the user's voice transmit mode as a `TransmitMode` enum with variants `Ptt`, `Continuous`, and `VoiceActivity`. `VoiceActivity` shall be selectable only on platforms with an implemented and verified VAD capture path in this baseline (currently Windows/Linux desktop); mobile, macOS, and unverified-platform enablement remain deferred per DEC-030. The setting shall be persisted per identity via the identity store where the selected platform supports it. The default value for a fresh install shall be `Ptt`. The UI shall render `VoiceActivity` as disabled/unavailable on unsupported platforms rather than claiming runtime support.
- Status: Baseline Candidate
- Type: Software Interface Requirement
@@ -2837,7 +2837,7 @@ Consistent with the SysRS-307 / SysRS-308 / SysRS-309 deferrals propagated throu
| Version | Date | Description |
|---|---|---|
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle software requirements SRS-204 through SRS-207 sourced from SysDes-149..151: bridge surface drops `start_audio` / `stop_audio` and adds `voice_join(channel_id)` / `voice_leave()`, audio engine opens streams on first voice-channel join and closes on last leave with output independent of mic-permission state, `TransmitMode` enum (`Ptt` default, `Continuous`, reserved `VoiceActivity` per DEC-030) persisted per identity, `release_tail_ms` (default 200, range 0500) gate on the `true → false` transition of `transmit_active` with re-press cancellation, Voice Bar hard-mute override of `transmit_active`. Strict layered sourcing preserved (`SRS -> SysDes` only). |
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle software requirements SRS-204 through SRS-207 sourced from SysDes-149..151: bridge surface drops `start_audio` / `stop_audio` and adds `voice_join(channel_id)` / `voice_leave()`, audio engine opens streams on first voice-channel join and closes on last leave with output independent of mic-permission state, `TransmitMode` enum (`Ptt` default, `Continuous`, platform-scoped `VoiceActivity` per DEC-030) persisted per identity where supported, `release_tail_ms` (default 200, range 0500) gate on the `true → false` transition of `transmit_active` with re-press cancellation, Voice Bar hard-mute override of `transmit_active`. Strict layered sourcing preserved (`SRS -> SysDes` only). |
## Baseline Candidate 0.9.6 Update

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