Compare commits

..
Author SHA1 Message Date
Edison Jwa 370dd37a22 docs: mark test tools and platform eval complete (TODO-024,044,047)
TODO-024: leave as-is (single-crate cfg gates correct architecture).
TODO-044: audio-test tool implemented. TODO-047: protocol-probe tool implemented.
2026-06-11 21:59:16 +09:00
Edison Jwa 523eafd4d7 docs: evaluate platform backend extraction — leave as-is (TODO-024)
Single-crate with module-level cfg gates is correct architecture.
Splitting would add dependency complexity with no compilation benefit.
2026-06-11 21:58:32 +09:00
Edison Jwa 6c00fae1cf feat(tools): add protocol probe and audio test binaries (TODO-044,047)
Protocol probe: connects to server, reports capabilities, channels,
clients. Audio test: DSP pipeline benchmark with configurable params.
2026-06-11 21:58:26 +09:00
Edison Jwa 11a2541042 docs: mark TSDNS, file transfer verified complete (TODO-066,067)
Both features already fully implemented. TSDNS in chanora_resolver,
file transfer in chanora_core + chanora_cache.
2026-06-11 21:45:59 +09:00
Edison Jwa fe3da41e41 docs: add TSDNS/file transfer designs, review p256 PR draft (TODO-057,066,067)
TSDNS: verified fully implemented in chanora_resolver (port 41144,
magic bytes, TCP query). File transfer: verified fully implemented
for avatars/icons (ftinitdownload, TCP data, cacache). p256 PR draft
reviewed and corrected (function path, probability figure).
2026-06-11 21:44:31 +09:00
Edison Jwa 602eedc029 fix(protocol,bridge): wire nickname validation to connect (TODO-045)
Add validate_nickname() to ProtocolClient::connect() and bridge
connect(). Trim whitespace, reject empty, truncate to 30 chars.
Re-export validate_nickname from protocol and core.
2026-06-11 21:44:03 +09:00
Edison Jwa 020218a7a1 docs: add badge fetching design, update verified items (TODO-042,058,062,065)
Badge fetching design with Protobuf parsing, 24h cache refresh, cacache
integration. Mark TODO-042 verified, TODO-058 monitoring, TODO-062 complete.
2026-06-11 21:30:27 +09:00
Edison Jwa 1e774035d1 docs(references): backfill YaTQA anti-flood action table (TODO-061)
Fill in 52 anti-flood action point values from category summary.
All entries now have values. Zero-cost through extreme (158) tiers.
2026-06-11 21:30:05 +09:00
Edison Jwa acc1450904 docs: update master todo list with Wave 3-5 status (TODO-026,042)
Mark TODO-026 as leave-as-is with reasoning. Mark TODO-042 as verified
complete. Update all Wave 3-5 items with completion status.
2026-06-11 21:07:29 +09:00
Edison Jwa 72ded4e011 docs: add test tool designs and iOS test plan (TODO-020,043,044,046,047)
Design documents for event replay, audio processing test, audio
loopback test, and protocol probe tools. iOS audio session test
plan with 28 scenarios and 3-phase automation approach.
2026-06-11 21:04:45 +09:00
Edison Jwa c04aaf4a51 docs(flutter): add dart doc comments to core services and widgets (TODO-085)
Add dart doc comments to poke_notification_service, poke_preferences_service,
link_trust_service, voice_settings, snapshot_view covering public API.
2026-06-11 21:03:19 +09:00
Edison Jwa c4b8732bd7 docs: add upstream PR draft, benchmarks, web client assessment (TODO-057,072,073)
p256 PR draft ready for ReSpeak/tsclientlib submission.
Performance benchmarks document with tsclientlib baselines and
Chanora current metrics. Web client assessed as infeasible without
UDP proxy infrastructure.
2026-06-11 20:55:20 +09:00
Edison Jwa 7968f90f7d docs: evaluate tsdeclarations codegen, map permissions, verify encoding (TODO-063,068,069)
tsdeclarations eval: direct codegen redundant (already via tsproto),
but permission ID generation and error code enum have high value.
Permission mapping: 253 TS3 permissions identified, 7 P1 for chat/moderation.
Encoding verification: tsclientlib uses strict UTF-8, UCS-2 is edge case.
2026-06-11 20:54:40 +09:00
Edison Jwa 508fa8b408 ci: add Linux multi-distro matrix and coverage reporting (TODO-052,053)
Add Docker-based Linux build matrix (Ubuntu, Fedora, Arch) for
platform-specific issue detection. Add cargo-llvm-cov coverage
job with artifact upload.
2026-06-11 20:44:56 +09:00
Edison Jwa 00e3fa7ad5 fix(flutter): request notification permission proactively (TODO-041)
When user enables poke notifications, call requestPermission()
immediately so OS prompt appears on first enable, not lazily.
2026-06-11 20:44:17 +09:00
Edison Jwa ef19f4e7ce feat(flutter): persist voice settings and per-user volume (TODO-039,040)
Add SharedPreferences persistence for transmit mode, release tail,
input/output device. Per-user volume/mute persisted across sessions.
2026-06-11 20:43:14 +09:00
Edison Jwa 7ab50a4eb5 refactor(flutter): split main.dart into screens/ directory (TODO-023)
Extract BetaHome (2374 lines) to screens/home_screen.dart and
LiveDiagnosticsDialog (97 lines) to screens/diagnostics_dialog.dart.
main.dart reduced from 2910 to 255 lines. No behavioral changes.
2026-06-11 20:42:38 +09:00
Edison Jwa 0c8efa38f1 fix(protocol): add input validation per SRS-094 (TODO-045)
Add validate_and_truncate(), validate_nickname(), validate_message(),
validate_poke_message(), validate_channel_name() with length limits.
Wire to send_text_message in adapter. 14 new validation tests.
2026-06-11 14:49:24 +09:00
Edison Jwa af823da543 feat(protocol): add client-side anti-flood awareness (TODO-064)
Implement FloodTracker with server-configurable thresholds from
YaTQA §5.1 model: 0.5s tick interval, point costs per operation,
NearLimit/CommandBlocked/IpBlocked risk levels. Defaults from
TeaSpeak (tick_reduce=25, command_block=150, ip_block=300).
2026-06-11 14:49:19 +09:00
Edison Jwa b944bd89d7 refactor(protocol): use lost() helper method in adapter (TODO-025 cleanup)
Replace ProtocolError::Lost(msg.to_string()) with ProtocolError::lost(msg)
for consistent error construction across the protocol crate.
2026-06-11 14:42:39 +09:00
Edison Jwa 475f6e0603 docs: verify DNS/reconnect, revise encoding scope, document protocol traps (TODO-059,062,063,070)
Mark TODO-059 (auto-reconnect) and TODO-062 (DNS chain) as verified
complete with evidence. Revise TODO-063 from implementation to
verification task (tsclientlib handles encoding). Create protocol
implementation traps doc with 13 traps from YaTQA/ReSpeak/TeaSpeak.
2026-06-11 14:42:32 +09:00
Edison Jwa 008128defc docs(audio): improve doc comments on key modules (TODO-031)
Add/improve dart doc comments for frame converters, AudioEngineConfig,
VadOutput variants, WebRtcFallbackVad, SileroOnnxVad, AppleCoreMlVad.
2026-06-11 13:31:12 +09:00
Edison Jwa dd6fa6121e feat(bridge,core): add is_hard_muted and get_output_gain readback (TODO-037)
Add bridge read-back functions for hard-mute state and output gain.
Add core output_gain() getter. Document feature gaps in SRS.
2026-06-11 13:30:52 +09:00
Edison Jwa 8dbe767d4f docs(architecture): update SysDes, SDD, SAD with missing modules and SysRS allocations (TODO-028,034,035)
SysDes: add SysRS-310 allocation, update coverage statement.
SDD: add SDD-MOD-015/016/017 for resolver/prefetch/cache, update engine split.
SAD: add Cache component to architecture table and diagram.
2026-06-11 13:25:21 +09:00
Edison Jwa f509c370b3 docs: add branch management workflow guide (TODO-088)
Document branch naming conventions, git worktree setup, PR stacking
patterns, and local CI mirroring recommendations.
2026-06-11 12:25:28 +09:00
Edison Jwa 2948c029d0 test(flutter): add 34 widget tests for voice_bar, connect, settings (TODO-017)
Voice bar: 12 tests covering modes, PTT badge, talk power, callbacks.
Connect widgets: 12 tests covering form, bookmark list, sanitization.
Voice settings: 10 tests covering result state, clamping, mode coverage.
2026-06-11 12:25:22 +09:00
Edison Jwa 40883c40c8 refactor: reduce map_err boilerplate with context helper methods (TODO-025)
Add per-crate variant_ctx() helper methods to StorageError, BlobCacheError,
ProtocolError, and BridgeError. Replace 78 .map_err(|e| format!(...))
closures with concise context calls. Identical error messages preserved.
2026-06-11 12:25:00 +09:00
Edison Jwa 1efeaac19d refactor(audio): split engine.rs into engine/ module directory (TODO-022)
Split monolithic engine.rs (3,244 lines) into focused modules:
mod.rs (1,106), capture.rs (515), render.rs (176), lifecycle.rs (1,149).
Capture and render are desktop-only (cfg-gated). No behavioral changes.
All public API paths preserved.
2026-06-11 12:05:54 +09:00
Edison Jwa 6d2405f67a test(audio): add poisoned-mutex survival tests (TODO-016)
Verify mutex recovery produces usable inner value after poisoning.
5 tests covering value preservation, mutable recovery, Arc clone,
and snapshot-through-guard patterns.
2026-06-11 12:05:44 +09:00
Edison Jwa 01a4a9ed28 docs: add README files to 9 crates and update verification plan (TODO-030,036)
Add purpose, architecture, and public API summary to each crate
README following chanora_resolver pattern. Update verification master
plan with new evidence sources and entry/exit criteria.
2026-06-11 11:09:12 +09:00
Edison Jwa 93f4608250 ci: add Android/Windows/macOS builds, xcodebuild archive, clippy gate (TODO-048,049,050,051)
Add Android multi-ABI build (arm64-v8a, armeabi-v7a, x86_64) with
cargo-ndk. Add Windows and macOS cargo-check jobs. Add xcodebuild
archive verification to iOS release build. Make clippy a blocking
PR gate by removing continue-on-error.
2026-06-11 11:06:06 +09:00
Edison Jwa e14dd73570 test(protocol,core,bridge): add 83 unit tests for untested modules (TODO-015,018,019,021)
Protocol DTO: 32 serde roundtrip + edge case tests.
Core events: 27 event construction + variant coverage tests.
Bridge: 24 error mapping + DTO roundtrip tests.
Add serde_json dev-dependency to protocol and bridge crates.
2026-06-11 11:05:11 +09:00
Edison Jwa 2ab8d5aae2 docs(references): add Chanora relevance, backfill placeholders, cross-reference (TODO-079,080,081,083,084)
Add TeaSpeak-to-Chanora SRS mapping and divergence points.
Backfill YaTQA variable parameters, anti-flood actions, and notify
events with structured placeholders. Add error code cross-reference
between YaTQA and ReSpeak sources.
2026-06-11 10:12:35 +09:00
Edison Jwa 78ecafcc2c docs: update implementation status and fix traceability coverage (TODO-027,029)
Update implementation-status to 2026-06-11: add poke notifications,
file transfer, cache, adaptive layout, VAD, voice controls coverage.
Correct traceability matrix coverage statement for sections 14-19.
2026-06-11 10:12:27 +09:00
Edison Jwa 8c9eba15c3 chore: convert TODO/FIXME to tracked references (TODO-008)
Convert all remaining raw TODO/FIXME/HACK comments to TRACKED()
references cross-linked to master todo list items. No behavior changes.
2026-06-11 10:05:14 +09:00
Edison Jwa 292617f8e8 refactor(core,storage): deduplicate code patterns (TODO-014)
Extract PttCapability event construction helper in events.rs.
Extract ensure_dir() helper in chanora_storage. Add TODO(refactor)
annotations for patterns requiring shared crate or API changes.
2026-06-11 10:05:06 +09:00
Edison Jwa 484522cbb6 refactor(flutter): extract shared AudioProcessingPanel widget (TODO-013)
Deduplicate audio processing toggle UI from voice_compact.dart and
voice_settings.dart into shared AudioProcessingPanel widget in
voice_settings_controls.dart. Centralize platform detection getters
in voice_platform.dart.
2026-06-11 10:04:58 +09:00
Edison Jwa ecdd68eff2 refactor(flutter): deduplicate AudioProcessingConfig construction (TODO-012)
Extract 4 identical BridgeAudioProcessingConfig constructions into
single shared construction, computing only platform-specific fields
in branches.
2026-06-11 09:47:02 +09:00
Edison Jwa b2937da726 fix(protocol,storage): platform-aware client version and Android stub (TODO-010,011)
pick_client_version() now returns correct version per platform via
cfg(target_os). keyring_load() on unsupported platforms returns
StorageError instead of silently returning Ok(None).
2026-06-11 09:46:57 +09:00
Edison Jwa b20e6b663a fix(audio): replace Mutex unwrap with poisoned-mutex recovery (TODO-006)
Replace 42 .lock().unwrap() calls with .unwrap_or_else(|e| e.into_inner())
across 7 files. Poisoned mutex recovery prevents panics in realtime audio
callbacks. Add SAFETY comment to WebRtcFallbackVad Send impl (TODO-007).
2026-06-11 09:46:51 +09:00
Edison Jwa 13c7648a65 docs: link orphaned superpowers docs and track Flutter bugs (TODO-033,087)
Add docs/superpowers/README.md as index for plans and specs.
Add docs/references/flutter-upstream-bugs.md tracking 4 known
Flutter framework workarounds.
2026-06-11 09:40:08 +09:00
Edison Jwa 328776622f docs(references): fix formatting issues (TODO-074,075,076,077,078,082)
Fix malformed MIFCOM badge table row, replace section numbering
placeholder, split respeak reference into Part I/II, add version
notes for teaspeak contradictions, fix truncated YaTQA error codes.
2026-06-11 09:40:03 +09:00
Edison Jwa 7dbf461262 refactor(audio,state): remove dead code (TODO-001,002,004)
Remove unused AudioFrame10ms/20ms structs, disable_failed_vad_backend(),
and ServerState dead accessors (replace_from_snapshot, channel_count,
client_count). Update tests to use .channels().count()/.clients().count().
2026-06-11 09:39:57 +09:00
Edison Jwa 3b13a7edb4 docs: comprehensive codebase analysis and reference documentation
Add full codebase analysis report, issue history, test environment
requirements, external research (TeaSpeak/ReSpeak/YaTQA), and offline
protocol references (ReSpeak ts3protocol, TeaSpeak, YaTQA). Add master
TODO list with 88 items across 11 categories. Add missing LICENSE files.

Branch: docs/codebase-analysis-v2
2026-06-11 09:23:10 +09:00
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 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
163 changed files with 29757 additions and 7128 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 }
+124 -15
View File
@@ -25,15 +25,10 @@ jobs:
run: cargo check --workspace --locked
- name: cargo test --workspace
env:
# Storage tests must not hit the real OS keyring on CI:
# there is no D-Bus session available and the call would
# block. The runtime code carries the same toggle for
# headless / sandboxed environments.
CHANORA_DISABLE_KEYRING: "1"
run: cargo test --workspace --locked --no-fail-fast
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
continue-on-error: true
supply-chain:
name: cargo deny (licenses + advisories + bans + sources)
@@ -43,9 +38,6 @@ jobs:
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
# `licenses` enforces the DEC-020 license posture; the
# other three are minimal supply-chain hygiene per
# `docs/governance/legal-review-readiness.md` §5.
arguments: --workspace --all-features
license-inventory:
@@ -58,10 +50,6 @@ jobs:
- name: Install cargo-about
run: cargo install --locked --features cli cargo-about
- name: Regenerate inventory and compare
# Build the inventory in a temp file and diff against the
# committed copy. CI fails when the committed inventory is
# stale, forcing contributors to run the tool locally
# before opening a PR that touches the dependency tree.
run: |
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
diff docs/security/license-inventory.md /tmp/license-inventory.md \
@@ -80,9 +68,6 @@ jobs:
run: flutter pub get
- name: Regenerate Flutter license inventory and compare
env:
# Resolved by the wrapper from $HOME/sdks/flutter when
# not set; CI's subosito/flutter-action puts flutter on
# PATH but exports the SDK root under FLUTTER_ROOT.
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
run: |
./tools/dump_flutter_licenses.sh
@@ -136,3 +121,127 @@ jobs:
if: steps.silero-coreml.outputs.available == 'true'
working-directory: apps/chanora_flutter
run: flutter build ios --release --no-codesign
- name: xcodebuild archive verification
if: steps.silero-coreml.outputs.available == 'true'
working-directory: apps/chanora_flutter
run: |
xcodebuild archive \
-workspace ios/Runner.xcworkspace \
-scheme Runner \
-archive build/Runner.xcarchive \
CODE_SIGNING_ALLOWED=NO \
| xcpretty || { echo "::error::xcodebuild archive failed — see issue-history-analysis.md §4 'Xcode Archive vs build divergence'"; exit 1; }
android-build:
name: Android build (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-android
abi: arm64-v8a
- target: armv7-linux-androideabi
abi: armeabi-v7a
- target: x86_64-linux-android
abi: x86_64
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- name: Install cargo-ndk
run: cargo install --locked cargo-ndk
- name: Setup NDK
run: |
ANDROID_ROOT="/usr/local/lib/android/sdk"
SDKMANAGER="$ANDROID_ROOT/cmdline-tools/latest/bin/sdkmanager"
echo "y" | $SDKMANAGER "ndk;27.0.12077973"
echo "ANDROID_NDK_HOME=$ANDROID_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV"
- name: cargo ndk build
run: cargo ndk -t ${{ matrix.abi }} build --workspace --locked
windows-build:
name: Windows build
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo check --workspace
run: cargo check --workspace --locked
macos-build:
name: macOS build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo check --workspace
run: cargo check --workspace --locked
linux-multi-distro:
name: Linux build (${{ matrix.distro }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- distro: ubuntu
image: ubuntu:24.04
install: |
apt-get update
apt-get install -y curl build-essential pkg-config \
libasound2-dev libpulse-dev libdbus-1-dev libsdl2-dev libopus-dev
- distro: fedora
image: fedora:latest
install: |
dnf install -y curl gcc pkg-config \
alsa-lib-devel pulseaudio-libs-devel dbus-devel SDL2-devel opus-devel
- distro: arch
image: archlinux:latest
install: |
pacman -Syu --noconfirm
pacman -S --noconfirm curl base-devel pkg-config \
alsa-lib pulseaudio dbus sdl2 opus
container:
image: ${{ matrix.image }}
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: ${{ matrix.install }}
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: cargo check --workspace
run: cargo check --workspace
coverage:
name: cargo llvm-cov
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: System deps
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev libpulse-dev pkg-config \
libdbus-1-dev libsdl2-dev libopus-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
run: cargo install --locked cargo-llvm-cov
- name: Generate coverage
env:
CHANORA_DISABLE_KEYRING: "1"
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: lcov-report
path: lcov.info
Generated
+369 -15
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"
@@ -468,6 +663,7 @@ dependencies = [
"log",
"ndk-context",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -475,11 +671,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,11 +723,12 @@ name = "chanora_protocol"
version = "0.2.0-beta.1"
dependencies = [
"async-trait",
"base64",
"base64 0.22.1",
"chanora_resolver",
"futures",
"reqwest 0.13.4",
"serde",
"serde_json",
"thiserror 2.0.18",
"time",
"tokio",
@@ -554,7 +763,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 +1448,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 +1471,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 +1774,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 +2064,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 +2369,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 +2462,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 +2513,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 +2531,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 +3084,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 +3139,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 +3479,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 +3526,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 +3564,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 +3980,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 +4170,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 +4685,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 +4706,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 +4734,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 +4763,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 +4778,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 +4791,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 +4804,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 +4848,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 +4912,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 +5604,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 +5643,7 @@ dependencies = [
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"event-listener 5.4.1",
"futures-core",
"futures-lite",
"hex",
+4
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",
@@ -38,6 +40,8 @@ members = [
exclude = [
"apps/chanora_flutter",
"tools/protocol-probe",
"tools/audio-test",
]
[workspace.package]
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024-2026 Chanora Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Chanora Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -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
@@ -46,7 +46,8 @@ import AVFoundation
//
// VoIP configuration is engaged on voice-channel join via the
// `chanora/ios_audio_session` MethodChannel, driven from Dart
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
// before `voiceJoin` starts VoiceProcessingIO and again as an
// idempotent guard on the AudioStarted lifecycle.
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "launch-ambient")
@@ -79,8 +80,8 @@ import AVFoundation
}
/// Activate the VoIP audio session. Called from Dart via the
/// `chanora/ios_audio_session` channel when a voice channel join
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
/// `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:
+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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../l10n/generated/app_localizations.dart';
class LiveDiagnosticsDialog extends StatefulWidget {
const LiveDiagnosticsDialog({super.key, required this.diagnosticsTextBuilder});
final String Function() diagnosticsTextBuilder;
@override
State<LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState();
}
class _LiveDiagnosticsDialogState extends State<LiveDiagnosticsDialog> {
static const _refreshInterval = Duration(seconds: 1);
Timer? _refreshTimer;
String _text = '';
@override
void initState() {
super.initState();
_refresh();
_refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh());
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
void _refresh() {
final next = widget.diagnosticsTextBuilder();
if (!mounted || next == _text) return;
setState(() => _text = next);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final size = MediaQuery.sizeOf(context);
return AlertDialog(
title: Row(
children: [
Expanded(child: Text(l10n.diagnosticsAction)),
const SizedBox(width: 12),
Tooltip(
message: l10n.diagnosticsLiveUpdating,
child: Icon(
Icons.sync,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
content: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 720,
maxHeight: size.height * 0.65,
),
child: SingleChildScrollView(
child: SelectableText(
_text,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
),
actions: [
TextButton(
onPressed: () async {
await SharePlus.instance.share(ShareParams(text: _text));
},
child: Text(l10n.shareAction),
),
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: _text));
if (!context.mounted) return;
Navigator.of(context).pop();
},
child: Text(l10n.copyAction),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -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) {
@@ -148,12 +148,13 @@ void wireMacosAudioLifecycle({
try {
switch (call.method) {
case 'handleDefaultDeviceChange':
// TODO: call rust.macosDefaultDeviceChanged() once exposed
// via flutter_rust_bridge; until then the event is captured
// here for observability.
// TRACKED(macos-device-change): call rust.macosDefaultDeviceChanged()
// once exposed via flutter_rust_bridge; until then the event is
// captured here for observability.
break;
case 'handleConfigurationChange':
// TODO: same — currently captured, no engine action yet.
// TRACKED(macos-config-change): currently captured, no engine action
// yet — depends on Rust-side device-change API exposure.
break;
default:
break;
@@ -10,9 +10,9 @@ const iosAudioSessionChannelName = 'chanora/ios_audio_session';
/// 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] when the Rust engine
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
/// `BridgeEvent::AudioStopped`.
/// 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
@@ -3,11 +3,19 @@ import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages user-trusted link domains to suppress external-link warnings.
///
/// Trusted domains are persisted in [SharedPreferences] under
/// `'trusted_domains'`. Supports wildcard patterns (e.g. `'*.example.com'`)
/// that match any subdomain of the base host.
///
/// This is a singleton; use [LinkTrustService.instance] to obtain it.
class LinkTrustService extends ChangeNotifier {
static LinkTrustService? _instance;
final Set<String> _trusted = {};
bool _loaded = false;
/// Returns the singleton [LinkTrustService] instance.
static LinkTrustService get instance {
_instance ??= LinkTrustService._();
return _instance!;
@@ -26,6 +34,10 @@ class LinkTrustService extends ChangeNotifier {
notifyListeners();
}
/// Returns `true` if [host] matches any trusted domain pattern.
///
/// Matching is case-insensitive. Wildcard patterns like `'*.example.com'`
/// match both `example.com` and any `*.example.com` subdomain.
bool isTrusted(String host) {
host = host.toLowerCase();
for (final pattern in _trusted) {
@@ -34,6 +46,7 @@ class LinkTrustService extends ChangeNotifier {
return false;
}
/// Persists [host] as a trusted domain and notifies listeners.
Future<void> addTrustedDomain(String host) async {
host = host.toLowerCase();
_trusted.add(host);
@@ -51,6 +64,10 @@ class LinkTrustService extends ChangeNotifier {
}
}
/// Shows a dialog asking the user whether to open an external link.
///
/// Returns `true` if the user chose to open and checked "remember this domain",
/// `false` if the user chose to open without remembering, or `null` if cancelled.
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
bool remember = false;
return showDialog<bool>(
@@ -0,0 +1,198 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../src/rust/api.dart' as rust;
/// Manages local push notifications for TeamSpeak poke events.
///
/// Handles platform-specific notification configuration across Android,
/// iOS, macOS, Linux, and Windows. Notification sound is intentionally
/// delegated to [EventSoundService] (tracked: TODO-event-sounds); this
/// service only manages the visual notification surface.
///
/// Poke strength maps to platform-appropriate urgency levels:
/// - [BridgePokeStrength.strong] → high-priority / time-sensitive
/// - [BridgePokeStrength.suppressed] → normal priority
/// - [BridgePokeStrength.suppressedOverflow] → passive / low priority
class PokeNotificationService {
/// Creates a [PokeNotificationService] with an optional
/// [FlutterLocalNotificationsPlugin] for testing.
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;
/// Initializes the notification plugin with platform-specific settings.
///
/// Safe to call multiple times; subsequent calls are no-ops.
Future<void> init() async {
if (_initialized) return;
await _notifications.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_chanora_notification'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
requestSoundPermission: false,
defaultPresentSound: false,
),
macOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
requestSoundPermission: false,
defaultPresentSound: false,
),
linux: LinuxInitializationSettings(
defaultActionName: 'Open',
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
defaultSuppressSound: true,
),
windows: WindowsInitializationSettings(
appName: 'Chanora',
appUserModelId: _windowsAppUserModelId,
guid: _windowsGuid,
),
),
);
_initialized = true;
}
/// Requests notification permission from the user on the current platform.
///
/// Returns `true` on platforms where permission is not required (web,
/// Linux, Windows) or when the user grants permission.
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;
}
/// Displays a poke notification from [senderName] with [strength]-based
/// urgency. Silently returns if the user has denied notification permission.
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,
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
playSound: false,
silent: true,
groupKey: _groupKey,
category: AndroidNotificationCategory.message,
visibility: NotificationVisibility.private,
);
}
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
return DarwinNotificationDetails(
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
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(
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
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(
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
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,75 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Persists user preferences for TeamSpeak poke notifications.
///
/// Stores two settings:
/// - Whether pokes are globally enabled
/// - A set of muted sender client IDs
///
/// Preferences are written to [SharedPreferences] and observable
/// through [ValueListenable] so UI widgets can rebuild reactively.
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>{},
);
/// Whether poke notifications are globally enabled.
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
/// Set of client IDs whose pokes are muted.
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
/// Loads persisted preferences from [SharedPreferences].
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();
}
/// Enables or disables poke notifications globally.
Future<void> setPokesEnabled(bool enabled) async {
_pokesEnabled.value = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enabledKey, enabled);
}
/// Adds [senderId] to the muted senders set.
Future<void> muteSender(BigInt senderId) async {
if (_mutedSenders.value.contains(senderId)) return;
_mutedSenders.value = {..._mutedSenders.value, senderId};
await _saveMutedSenders();
}
/// Removes [senderId] from the muted senders set.
Future<void> unmuteSender(BigInt senderId) async {
if (!_mutedSenders.value.contains(senderId)) return;
_mutedSenders.value = _mutedSenders.value
.where((mutedSender) => mutedSender != senderId)
.toSet();
await _saveMutedSenders();
}
/// Returns `true` if [senderId] is in the muted senders set.
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(),
);
}
/// Releases [ValueNotifier] resources.
void dispose() {
_pokesEnabled.dispose();
_mutedSenders.dispose();
}
}
@@ -18,11 +18,19 @@ class UiSettings {
this.host = '',
this.nickname = '',
this.themeMode = UiThemeMode.system,
this.transmitModeIndex,
this.releaseTailMs,
this.inputDeviceId,
this.outputDeviceId,
});
final String host;
final String nickname;
final UiThemeMode themeMode;
final int? transmitModeIndex;
final int? releaseTailMs;
final String? inputDeviceId;
final String? outputDeviceId;
}
class UiPreferencesService {
@@ -30,6 +38,10 @@ class UiPreferencesService {
static const _nicknameKey = 'ui.nickname';
static const _themeModeKey = 'ui.theme_mode';
static const _permissionsExplainedKey = 'perms_explained';
static const _transmitModeIndexKey = 'voice.transmit_mode_index';
static const _releaseTailMsKey = 'voice.release_tail_ms';
static const _inputDeviceIdKey = 'audio.input_device_id';
static const _outputDeviceIdKey = 'audio.output_device_id';
const UiPreferencesService();
@@ -39,6 +51,10 @@ class UiPreferencesService {
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
transmitModeIndex: prefs.getInt(_transmitModeIndexKey),
releaseTailMs: prefs.getInt(_releaseTailMsKey),
inputDeviceId: prefs.getString(_inputDeviceIdKey),
outputDeviceId: prefs.getString(_outputDeviceIdKey),
);
}
@@ -53,6 +69,34 @@ class UiPreferencesService {
await prefs.setString(_themeModeKey, themeMode.name);
}
Future<void> saveTransmitModeIndex(int index) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_transmitModeIndexKey, index);
}
Future<void> saveReleaseTailMs(int ms) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_releaseTailMsKey, ms);
}
Future<void> saveInputDeviceId(String? id) async {
final prefs = await SharedPreferences.getInstance();
if (id == null) {
await prefs.remove(_inputDeviceIdKey);
} else {
await prefs.setString(_inputDeviceIdKey, id);
}
}
Future<void> saveOutputDeviceId(String? id) async {
final prefs = await SharedPreferences.getInstance();
if (id == null) {
await prefs.remove(_outputDeviceIdKey);
} else {
await prefs.setString(_outputDeviceIdKey, id);
}
}
Future<bool> hasExplainedPermissions() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_permissionsExplainedKey) ?? false;
@@ -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,
@@ -27,6 +27,7 @@ class AudioDeviceListTile extends StatefulWidget {
AudioDeviceListLoader? loadDevices,
AudioDeviceSetter? setInputDevice,
AudioDeviceSetter? setOutputDevice,
this.onDeviceChanged,
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
setInputDevice = setInputDevice ?? rust.setInputDevice,
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
@@ -46,6 +47,10 @@ class AudioDeviceListTile extends StatefulWidget {
/// Selects an output device.
final AudioDeviceSetter setOutputDevice;
/// Called after a device selection succeeds. Receives the device id
/// (null for system default).
final ValueChanged<String?>? onDeviceChanged;
@override
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
}
@@ -88,6 +93,7 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
setState(() {
_selectedDeviceId = deviceId;
});
widget.onDeviceChanged?.call(deviceId);
final selectedName = _selectedDevice?.name ?? 'System default';
ScaffoldMessenger.of(context).showSnackBar(
@@ -93,78 +93,51 @@ class AudioProcessingConfigState {
isLinux: linux,
);
rust.BridgeAudioBackend processingBackend;
rust.BridgeEffectOwner aec;
rust.BridgeEffectOwner ns;
rust.BridgeEffectOwner agc;
if (android) {
final owner = preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: preferHardware
processingBackend = preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
if (appleVoiceProcessing) {
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vad,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.platform,
agc: rust.BridgeEffectOwner.platform,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
if (desktopWebrtcApm) {
: rust.BridgeAudioBackend.webrtcApm;
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
} else if (appleVoiceProcessing) {
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
aec = rust.BridgeEffectOwner.platform;
ns = rust.BridgeEffectOwner.platform;
agc = rust.BridgeEffectOwner.platform;
} else if (desktopWebrtcApm) {
final owner = rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
processingBackend = rust.BridgeAudioBackend.webrtcApm;
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
} else {
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
aec = rust.BridgeEffectOwner.platform;
ns = nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off;
agc = agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off;
}
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
processingBackend: processingBackend,
vadBackend: vad,
aec: rust.BridgeEffectOwner.platform,
ns: nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off,
agc: agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off,
aec: aec,
ns: ns,
agc: agc,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
@@ -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,103 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/poke_notification_service.dart';
import '../services/poke_preferences_service.dart';
import 'voice_settings_controls.dart';
class PokeNotificationSettingsDialog extends StatelessWidget {
const PokeNotificationSettingsDialog({
super.key,
required this.preferences,
required this.notificationService,
});
final PokePreferencesService preferences;
final PokeNotificationService notificationService;
@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) async {
await preferences.setPokesEnabled(value);
// Request notification permission when enabling pokes
// so the OS prompt appears immediately rather than on
// the first poke event.
if (value) {
await notificationService.requestPermission();
}
},
),
),
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),
),
],
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/channel_spacer.dart';
@@ -10,7 +11,14 @@ import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
import 'talk_power_warning.dart';
/// Connected-server snapshot with welcome text, channels, and clients.
/// Connected-server snapshot displaying welcome text, channel tree, and clients.
///
/// Renders the full channel hierarchy from a [BridgeSnapshot] with expandable
/// channel nodes, client voice-status indicators, unread-message badges, and
/// context menus for client actions (info, chat, poke, volume).
///
/// Channel join is triggered by tapping an unlocked channel row; password-
/// protected channels invoke [onJoinChannelWithPassword] instead.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
const SnapshotView({
@@ -571,11 +579,40 @@ class _ClientVolumePreference {
}
class _ClientVolumePreferences extends ChangeNotifier {
_ClientVolumePreferences._();
_ClientVolumePreferences._() {
_load();
}
static final instance = _ClientVolumePreferences._();
static const _prefsKey = 'client_volume_prefs';
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
bool _loaded = false;
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_prefsKey) ?? const [];
for (final entry in raw) {
final parts = entry.split(':');
if (parts.length == 3) {
final id = BigInt.tryParse(parts[0]);
final volume = double.tryParse(parts[1]);
final muted = parts[2] == '1';
if (id != null && volume != null) {
_byClientId[id] = _ClientVolumePreference(
volume: volume,
muted: muted,
);
}
}
}
notifyListeners();
} catch (_) {}
}
_ClientVolumePreference preferenceFor(BigInt clientId) {
return _byClientId[clientId] ?? const _ClientVolumePreference();
@@ -588,6 +625,17 @@ class _ClientVolumePreferences extends ChangeNotifier {
_byClientId.remove(clientId);
}
notifyListeners();
_save();
}
Future<void> _save() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = _byClientId.entries
.map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}')
.toList();
await prefs.setStringList(_prefsKey, raw);
} catch (_) {}
}
}
@@ -1108,7 +1156,7 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
}
}
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
/// Context menu for channel tiles offering "Chat" on right-click or
/// long-press. Primary tap passes through to the child for voice join.
class _ChannelContextMenu extends StatelessWidget {
const _ChannelContextMenu({
@@ -26,21 +26,6 @@ import 'voice_settings_controls.dart';
import 'voice_status_summary.dart';
import '../src/rust/api.dart' as rust;
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
}
bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
bool get _isDesktopSileroVadHost {
if (kIsWeb) return false;
return Platform.isWindows || Platform.isLinux;
}
/// Two-line status chip that summarises the current voice state.
/// Tap to open the voice details modal.
class VoiceStatusChip extends StatelessWidget {
@@ -638,6 +623,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,
@@ -731,129 +723,11 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
const SizedBox(height: 4),
// Android HW/SW selector.
if (Platform.isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) {
setState(() => _audioProcessing.preferHardware = s.first);
_notifyAudioConfig();
},
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
: 'Software mode applies the full WebRTC APM stage set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
AudioProcessingPanel(
config: _audioProcessing,
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: Platform.isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) {
setState(() => _audioProcessing.limiterEnabled = v);
_notifyAudioConfig();
},
),
// VAD backend.
const SizedBox(height: 8),
Text(
'Voice activity detection (VAD)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);
update: (mutation) {
setState(mutation);
_notifyAudioConfig();
},
),
@@ -2,9 +2,17 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard the user would bind a PTT key on.
bool get isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files.
// Centralize all platform checks here and update call sites to use these
// getters instead of raw Platform.isAndroid/isIOS/etc.
bool get _notWeb => !kIsWeb;
bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid);
bool get isAndroidHost => _notWeb && Platform.isAndroid;
bool get isIosHost => _notWeb && Platform.isIOS;
bool get isMacOsHost => _notWeb && Platform.isMacOS;
bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux);
@@ -7,9 +7,6 @@
// - VAD backend
// - platform audio-processing mode selection where available
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
@@ -22,27 +19,8 @@ import 'voice_platform.dart';
import 'voice_settings_controls.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
}
bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
bool get _isDesktopSileroVadHost {
if (kIsWeb) return false;
return Platform.isWindows || Platform.isLinux;
}
/// Result returned by [VoiceSettingsDialog].
/// Result returned by [VoiceSettingsDialog] when the user saves or
/// requests a key bind.
class VoiceSettingsResult {
const VoiceSettingsResult({
required this.mode,
@@ -57,7 +35,15 @@ class VoiceSettingsResult {
final rust.BridgeAudioProcessingConfig audioConfig;
}
/// Voice + audio processing settings dialog.
/// Dialog for configuring voice transmission and audio processing settings.
///
/// Surfaces transmit mode selection (continuous, PTT, voice-activity),
/// PTT release-tail slider, key-bind request, and a full audio processing
/// panel covering noise suppression, echo cancellation, AGC, HPF, and VAD
/// backend selection.
///
/// On mobile hosts, the voice-activity segment is hidden when no
/// Chanora-owned VAD pipeline is available (iOS, macOS, web).
class VoiceSettingsDialog extends StatefulWidget {
const VoiceSettingsDialog({
super.key,
@@ -70,6 +56,8 @@ class VoiceSettingsDialog extends StatefulWidget {
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
this.onInputDeviceChanged,
this.onOutputDeviceChanged,
});
final rust.BridgeTransmitMode initialMode;
@@ -81,6 +69,8 @@ class VoiceSettingsDialog extends StatefulWidget {
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
final ValueChanged<String?>? onInputDeviceChanged;
final ValueChanged<String?>? onOutputDeviceChanged;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
@@ -106,7 +96,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
rust.BridgeAudioProcessingConfig _buildConfig() {
return _audioProcessing.buildConfig(
base: widget.initialAudioConfig,
isAndroid: _isAndroid,
isAndroid: isAndroidHost,
);
}
@@ -128,7 +118,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),
),
@@ -184,91 +179,9 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const Divider(height: 24),
const VoiceSectionHeader('Audio processing'),
// Android HW/SW selector
if (_isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.preferHardware = s.first),
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
: 'Android software mode applies the full WebRTC APM control set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
// DSP toggles
const VoiceSubHeader('DSP stages'),
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
if (!_isIos &&
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) => setState(() => _audioProcessing.aecEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
AudioProcessingPanel(
config: _audioProcessing,
update: (mutation) => setState(mutation),
),
if (isTalkPowerBlocked(
@@ -284,22 +197,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
),
],
// ── VAD ────────────────────────────────────────────────
const Divider(height: 24),
const VoiceSectionHeader('Voice activity detection (VAD)'),
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
),
const SizedBox(height: 8),
// ── PTT capability badge ────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt &&
widget.pttLevel.isNotEmpty) ...[
@@ -313,23 +210,25 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
],
// ── Audio output route picker (mobile only) ─────────────
if (_isAndroid || _isIos) ...[
if (isAndroidHost || isIosHost) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio output'),
const AudioOutputTile(),
],
// ── Audio devices (desktop only, SRS-026) ──────────────
if (!_isAndroid && !_isIos) ...[
if (!isAndroidHost && !isIosHost) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio devices'),
const AudioDeviceListTile(
AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
onDeviceChanged: widget.onInputDeviceChanged,
),
const AudioDeviceListTile(
AudioDeviceListTile(
label: 'Output',
kind: AudioDeviceKind.output,
onDeviceChanged: widget.onOutputDeviceChanged,
),
const SizedBox(height: 8),
],
@@ -1,5 +1,10 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'audio_processing_config_state.dart';
import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
/// Shared compact style for voice settings segmented buttons.
@@ -10,7 +15,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 +38,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(
@@ -186,3 +232,173 @@ class AudioProcessingToggleRow extends StatelessWidget {
);
}
}
typedef AudioFieldUpdater = void Function(VoidCallback mutation);
class AudioProcessingPanel extends StatelessWidget {
const AudioProcessingPanel({
super.key,
required this.config,
required this.update,
this.dense = false,
});
final AudioProcessingConfigState config;
final AudioFieldUpdater update;
final bool dense;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isAndroid = isAndroidHost;
final isIos = isIosHost;
final isMacOS = isMacOsHost;
final isDesktopSilero = isDesktopSileroVadHost;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isAndroid) ...[
if (!dense) const VoiceSubHeader('Processing backend'),
if (dense) _compactLabel(context, 'Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {config.preferHardware},
onSelectionChanged: (s) {
update(() => config.preferHardware = s.first);
},
),
const SizedBox(height: 4),
Text(
config.preferHardware
? (dense
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
: 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.')
: (dense
? 'Software mode applies the full WebRTC APM stage set.'
: 'Android software mode applies the full WebRTC APM control set.'),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (!dense) const SizedBox(height: 8),
],
if (!dense) const VoiceSubHeader('DSP stages'),
if (isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
if (!isIos &&
(!isAndroid || androidShowsNsControl(config)))
AudioProcessingToggleRow(
dense: dense,
label: dense ? 'Noise suppression' : 'Noise suppression (NS)',
subtitle: dense
? 'Wiener filter'
: 'Wiener filter · stationary noise',
value: config.nsEnabled,
onChanged: (v) => update(() => config.nsEnabled = v),
),
if (!isIos &&
(!isAndroid || androidShowsAecControl(config)))
AudioProcessingToggleRow(
dense: dense,
label: dense ? 'Echo cancellation' : 'Echo cancellation (AEC3)',
subtitle: _aecSubtitle(isAndroid, isMacOS, config),
value: isMacOS ? true : config.aecEnabled,
onChanged:
isMacOS ? null : (v) => update(() => config.aecEnabled = v),
),
if (!isIos &&
(!isAndroid || androidShowsAgcControl(config)))
AudioProcessingToggleRow(
dense: dense,
label: dense ? 'Auto gain control' : 'Auto gain control (AGC2)',
subtitle: dense
? 'AGC2 · -18 dBFS target'
: 'RNN VAD-gated · -18 dBFS target',
value: config.agcEnabled,
onChanged: (v) => update(() => config.agcEnabled = v),
),
if (!isAndroid || androidShowsHpfControl(config))
AudioProcessingToggleRow(
dense: dense,
label: dense ? 'High-pass filter' : 'High-pass filter (HPF)',
subtitle: dense
? '80 Hz · DC removal'
: '80 Hz Butterworth · DC removal',
value: config.hpfEnabled,
onChanged: (v) => update(() => config.hpfEnabled = v),
),
if (!isIos &&
(!isAndroid || androidShowsLimiterControl(config)))
AudioProcessingToggleRow(
dense: dense,
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: config.limiterEnabled,
onChanged: (v) => update(() => config.limiterEnabled = v),
),
if (!dense) ...[
const Divider(height: 24),
const VoiceSectionHeader('Voice activity detection (VAD)'),
const VoiceSubHeader('Backend'),
] else ...[
const SizedBox(height: 8),
Text(
'Voice activity detection (VAD)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
],
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments:
isDesktopSilero ? desktopVadBackendSegments : vadBackendSegments,
selected: {config.vadBackend},
onSelectionChanged: (s) {
update(() => config.vadBackend = s.first);
},
),
if (!dense) const SizedBox(height: 8),
],
);
}
String _aecSubtitle(bool isAndroid, bool isMacOS, AudioProcessingConfigState c) {
if (isAndroid) {
return c.preferHardware
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
: 'WebRTC AEC3 · adaptive filter';
}
if (isMacOS) return 'Managed by platform VPIO';
return 'WebRTC AEC3 · adaptive filter';
}
Widget _compactLabel(BuildContext context, String text) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}
@@ -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,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);
});
}
@@ -67,4 +67,44 @@ void main() {
expect(settings.themeMode, UiThemeMode.system);
});
test('loads null voice settings when unset', () async {
final settings = await service.loadSettings();
expect(settings.transmitModeIndex, isNull);
expect(settings.releaseTailMs, isNull);
expect(settings.inputDeviceId, isNull);
expect(settings.outputDeviceId, isNull);
});
test('saves and loads transmit mode index', () async {
await service.saveTransmitModeIndex(1);
final settings = await service.loadSettings();
expect(settings.transmitModeIndex, 1);
});
test('saves and loads release tail ms', () async {
await service.saveReleaseTailMs(300);
final settings = await service.loadSettings();
expect(settings.releaseTailMs, 300);
});
test('saves and loads audio device ids', () async {
await service.saveInputDeviceId('input-123');
await service.saveOutputDeviceId('output-456');
final settings = await service.loadSettings();
expect(settings.inputDeviceId, 'input-123');
expect(settings.outputDeviceId, 'output-456');
});
test('removes audio device id when set to null', () async {
await service.saveInputDeviceId('input-123');
await service.saveInputDeviceId(null);
final settings = await service.loadSettings();
expect(settings.inputDeviceId, isNull);
});
}
@@ -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,205 @@
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/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/connect_widgets.dart';
void main() {
group('ConnectForm', () {
late TextEditingController hostCtl;
late TextEditingController nickCtl;
late TextEditingController passwordCtl;
Widget buildForm({
VoidCallback? onConnect,
VoidCallback? onAddBookmark,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ConnectForm(
hostCtl: hostCtl,
nickCtl: nickCtl,
passwordCtl: passwordCtl,
onConnect: onConnect ?? () {},
onAddBookmark: onAddBookmark ?? () {},
),
),
);
}
setUp(() {
hostCtl = TextEditingController();
nickCtl = TextEditingController();
passwordCtl = TextEditingController();
});
tearDown(() {
hostCtl.dispose();
nickCtl.dispose();
passwordCtl.dispose();
});
testWidgets('renders all three text fields', (tester) async {
await tester.pumpWidget(buildForm());
expect(find.byType(TextField), findsNWidgets(3));
expect(find.byIcon(Icons.dns_outlined), findsOneWidget);
expect(find.byIcon(Icons.login), findsOneWidget);
expect(find.byIcon(Icons.bookmark_add_outlined), findsOneWidget);
});
testWidgets('connect callback fires on button tap', (tester) async {
var connected = false;
await tester.pumpWidget(buildForm(
onConnect: () => connected = true,
));
await tester.tap(find.byIcon(Icons.login));
expect(connected, isTrue);
});
testWidgets('bookmark callback fires on button tap', (tester) async {
var bookmarked = false;
await tester.pumpWidget(buildForm(
onAddBookmark: () => bookmarked = true,
));
await tester.tap(find.byIcon(Icons.bookmark_add_outlined));
expect(bookmarked, isTrue);
});
testWidgets('host field lowercases and strips whitespace', (tester) async {
await tester.pumpWidget(buildForm());
await tester.enterText(
find.widgetWithText(TextField, 'host[:port]'),
' MyServer.COM ',
);
await tester.pump();
expect(hostCtl.text, 'myserver.com');
});
testWidgets('password field is obscured', (tester) async {
await tester.pumpWidget(buildForm());
final passwordField = tester.widgetList<TextField>(
find.byType(TextField),
).last;
expect(passwordField.obscureText, isTrue);
});
testWidgets('form uses outlined border decoration', (tester) async {
await tester.pumpWidget(buildForm());
final fields = tester.widgetList<TextField>(find.byType(TextField));
for (final field in fields) {
final decoration = field.decoration as InputDecoration;
expect(decoration.border, isA<OutlineInputBorder>());
}
});
});
group('BookmarkList', () {
Widget buildBookmarkList({
required List<rust.BridgeBookmark> bookmarks,
ValueChanged<rust.BridgeBookmark>? onConnect,
ValueChanged<rust.BridgeBookmark>? onDelete,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: BookmarkList(
bookmarks: bookmarks,
onConnect: onConnect ?? (_) {},
onDelete: onDelete ?? (_) {},
),
),
),
);
}
const testBookmark = rust.BridgeBookmark(
id: 1,
displayName: 'My Server',
host: 'ts.example.com',
nickname: 'TestUser',
password: '',
);
const testBookmark2 = rust.BridgeBookmark(
id: 2,
displayName: 'Work Server',
host: 'work.ts.com',
nickname: 'WorkNick',
password: 'secret',
);
testWidgets('shows empty message when no bookmarks', (tester) async {
await tester.pumpWidget(buildBookmarkList(bookmarks: const []));
expect(find.byType(BookmarkList), findsOneWidget);
expect(find.byType(Card), findsNothing);
});
testWidgets('renders bookmark cards with name and host', (tester) async {
await tester.pumpWidget(buildBookmarkList(
bookmarks: const [testBookmark],
));
expect(find.text('My Server'), findsOneWidget);
expect(find.text('ts.example.com — TestUser'), findsOneWidget);
expect(find.byType(Card), findsOneWidget);
});
testWidgets('renders multiple bookmarks', (tester) async {
await tester.pumpWidget(buildBookmarkList(
bookmarks: const [testBookmark, testBookmark2],
));
expect(find.text('My Server'), findsOneWidget);
expect(find.text('Work Server'), findsOneWidget);
expect(find.byType(Card), findsNWidgets(2));
});
testWidgets('connect callback fires with correct bookmark', (tester) async {
rust.BridgeBookmark? connectedBookmark;
await tester.pumpWidget(buildBookmarkList(
bookmarks: const [testBookmark],
onConnect: (b) => connectedBookmark = b,
));
final connectButtons = find.byIcon(Icons.login);
await tester.tap(connectButtons.first);
expect(connectedBookmark, testBookmark);
});
testWidgets('delete callback fires with correct bookmark', (tester) async {
rust.BridgeBookmark? deletedBookmark;
await tester.pumpWidget(buildBookmarkList(
bookmarks: const [testBookmark],
onDelete: (b) => deletedBookmark = b,
));
final deleteButtons = find.byIcon(Icons.delete_outline);
await tester.tap(deleteButtons.first);
expect(deletedBookmark, testBookmark);
});
testWidgets('each bookmark card has connect and delete buttons', (tester) async {
await tester.pumpWidget(buildBookmarkList(
bookmarks: const [testBookmark],
));
expect(find.byIcon(Icons.login), findsOneWidget);
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
});
});
}
@@ -0,0 +1,43 @@
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_notification_service.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);
final notificationService = PokeNotificationService();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: PokeNotificationSettingsDialog(
preferences: preferences,
notificationService: notificationService,
),
),
);
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,170 @@
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/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_bar.dart';
void main() {
const defaultStats = rust.BridgeAudioStats(
framesSent: 100,
framesReceived: 200,
pttActive: false,
inputLevel: -30.0,
);
Widget buildVoiceBar({
bool inChannel = true,
rust.BridgeTransmitMode transmitMode = rust.BridgeTransmitMode.ptt,
bool hardMute = false,
bool outputMuted = false,
bool talkPowerBlocked = false,
int releaseTailMs = 150,
String channelName = 'Test Channel',
rust.BridgeAudioStats? audioStats = defaultStats,
double? inputLevel,
String pttLevel = 'L1WindowsHook',
String pttBackendId = 'windows-raw-input',
String pttBoundInputClass = 'keyboard',
String pttBoundKeyLabel = 'Space',
VoidCallback? onConfigure,
ValueChanged<bool>? onPttHeldChanged,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: VoiceBar(
inChannel: inChannel,
transmitMode: transmitMode,
hardMute: hardMute,
outputMuted: outputMuted,
talkPowerBlocked: talkPowerBlocked,
releaseTailMs: releaseTailMs,
channelName: channelName,
audioStats: audioStats,
inputLevel: inputLevel,
pttLevel: pttLevel,
pttBackendId: pttBackendId,
pttBoundInputClass: pttBoundInputClass,
pttBoundKeyLabel: pttBoundKeyLabel,
onConfigure: onConfigure ?? () {},
onPttHeldChanged: onPttHeldChanged ?? (_) {},
),
),
),
);
}
testWidgets('VoiceBar renders in PTT mode with stats', (tester) async {
await tester.pumpWidget(buildVoiceBar());
expect(find.byType(VoiceBar), findsOneWidget);
expect(find.byIcon(Icons.radio_button_checked), findsOneWidget);
expect(find.byIcon(Icons.tune), findsOneWidget);
});
testWidgets('VoiceBar renders in continuous mode', (tester) async {
await tester.pumpWidget(buildVoiceBar(
transmitMode: rust.BridgeTransmitMode.continuous,
));
expect(find.byType(VoiceBar), findsOneWidget);
expect(find.byIcon(Icons.podcasts), findsOneWidget);
});
testWidgets('VoiceBar shows channel status when in channel', (tester) async {
await tester.pumpWidget(buildVoiceBar(inChannel: true));
expect(find.byType(VoiceBar), findsOneWidget);
});
testWidgets('VoiceBar hides channel status when not in channel', (tester) async {
await tester.pumpWidget(buildVoiceBar(
inChannel: false,
channelName: '',
));
expect(find.byType(VoiceBar), findsOneWidget);
});
testWidgets('VoiceBar shows talk power blocked indicator', (tester) async {
await tester.pumpWidget(buildVoiceBar(
talkPowerBlocked: true,
));
expect(find.text('Insufficient talk power'), findsOneWidget);
});
testWidgets('VoiceBar shows audio stats line when stats available', (tester) async {
await tester.pumpWidget(buildVoiceBar());
expect(find.byType(VoiceBar), findsOneWidget);
});
testWidgets('VoiceBar hides audio stats line when stats null', (tester) async {
await tester.pumpWidget(buildVoiceBar(
audioStats: null,
));
expect(find.byType(VoiceBar), findsOneWidget);
});
testWidgets('VoiceBar onConfigure callback fires', (tester) async {
var configured = false;
await tester.pumpWidget(buildVoiceBar(
onConfigure: () => configured = true,
));
await tester.tap(find.byIcon(Icons.tune));
expect(configured, isTrue);
});
testWidgets('VoiceBar renders PTT capability badge in PTT mode', (tester) async {
await tester.pumpWidget(buildVoiceBar(
transmitMode: rust.BridgeTransmitMode.ptt,
pttLevel: 'L1WindowsHook',
));
expect(find.byIcon(Icons.public), findsOneWidget);
});
testWidgets('VoiceBar does not render PTT badge in continuous mode', (tester) async {
await tester.pumpWidget(buildVoiceBar(
transmitMode: rust.BridgeTransmitMode.continuous,
));
expect(find.byIcon(Icons.public), findsNothing);
});
testWidgets('VoiceBar renders with active PTT state', (tester) async {
const activeStats = rust.BridgeAudioStats(
framesSent: 500,
framesReceived: 300,
pttActive: true,
inputLevel: -10.0,
);
await tester.pumpWidget(buildVoiceBar(audioStats: activeStats));
expect(find.byType(VoiceBar), findsOneWidget);
});
testWidgets('VoiceBar renders with input level override', (tester) async {
await tester.pumpWidget(buildVoiceBar(
inputLevel: -20.0,
));
expect(find.byType(VoiceBar), findsOneWidget);
});
testWidgets('VoiceBar renders with null input level', (tester) async {
await tester.pumpWidget(buildVoiceBar(
audioStats: null,
inputLevel: null,
));
expect(find.byType(VoiceBar), findsOneWidget);
});
}
@@ -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]);
});
@@ -0,0 +1,152 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_settings.dart';
rust.BridgeAudioProcessingConfig _defaultConfig() {
return rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.webrtcApm,
vadBackend: rust.BridgeVadBackend.sileroOnnx,
aec: rust.BridgeEffectOwner.webrtcApm,
ns: rust.BridgeEffectOwner.webrtcApm,
agc: rust.BridgeEffectOwner.webrtcApm,
hpfEnabled: true,
limiterEnabled: true,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: false,
);
}
void main() {
group('VoiceSettingsResult', () {
test('stores PTT mode result', () {
final config = _defaultConfig();
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 200,
bindKeyRequested: true,
audioConfig: config,
);
expect(result.mode, rust.BridgeTransmitMode.ptt);
expect(result.releaseTailMs, 200);
expect(result.bindKeyRequested, isTrue);
expect(result.audioConfig, config);
});
test('stores continuous mode result without key binding', () {
final config = _defaultConfig();
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
bindKeyRequested: false,
audioConfig: config,
);
expect(result.mode, rust.BridgeTransmitMode.continuous);
expect(result.releaseTailMs, 0);
expect(result.bindKeyRequested, isFalse);
});
test('stores voice activity mode result', () {
final config = _defaultConfig();
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.voiceActivity,
releaseTailMs: 100,
bindKeyRequested: false,
audioConfig: config,
);
expect(result.mode, rust.BridgeTransmitMode.voiceActivity);
expect(result.releaseTailMs, 100);
});
test('preserves audio config fields', () {
final config = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.bluetoothHfp,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.webrtcVad,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.off,
agc: rust.BridgeEffectOwner.off,
hpfEnabled: false,
limiterEnabled: false,
vadHangoverMs: 300,
vadPreRollMs: 100,
vadMinTxMs: 150,
debugWavDumpEnabled: true,
);
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 250,
bindKeyRequested: false,
audioConfig: config,
);
expect(result.audioConfig.route, rust.BridgeAudioRoute.bluetoothHfp);
expect(result.audioConfig.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(result.audioConfig.ns, rust.BridgeEffectOwner.off);
expect(result.audioConfig.hpfEnabled, isFalse);
expect(result.audioConfig.debugWavDumpEnabled, isTrue);
});
test('release tail is always a round number', () {
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 123,
bindKeyRequested: false,
audioConfig: _defaultConfig(),
);
expect(result.releaseTailMs, 123);
expect(result.releaseTailMs, equals(result.releaseTailMs.round()));
});
test('bindKeyRequested defaults to false for save action', () {
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 150,
bindKeyRequested: false,
audioConfig: _defaultConfig(),
);
expect(result.bindKeyRequested, isFalse);
});
test('bindKeyRequested is true for bind-key action', () {
final result = VoiceSettingsResult(
mode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 150,
bindKeyRequested: true,
audioConfig: _defaultConfig(),
);
expect(result.bindKeyRequested, isTrue);
});
});
group('VoiceSettingsDialog state management', () {
test('release tail is clamped between 0 and 500', () {
expect(999.clamp(0, 500), 500);
expect((-10).clamp(0, 500), 0);
expect(250.clamp(0, 500), 250);
});
test('transmit mode enum covers all three modes', () {
expect(rust.BridgeTransmitMode.values, hasLength(3));
expect(
rust.BridgeTransmitMode.values,
containsAll([
rust.BridgeTransmitMode.ptt,
rust.BridgeTransmitMode.continuous,
rust.BridgeTransmitMode.voiceActivity,
]),
);
});
});
}
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
jni
)
+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" }
+74
View File
@@ -0,0 +1,74 @@
# chanora_core
Top-level Rust API and orchestration layer for the Chanora client. Composes subsystem crates behind a stable, typed API consumed by `chanora_bridge`. Owns no protocol, audio, or storage logic directly.
## Architecture
Per SAD §7.2, `chanora_core` is the integration point:
- **`ChanoraSession`** — the primary public type. Owns at most one active server connection (DEC-006). Provides connect, disconnect, snapshot, audio lifecycle, PTT, bookmarks, and diagnostics methods.
- **Supervisor** — a per-connection tokio task that monitors connection health via a loss notifier and a watchdog probe, and auto-reconnects with exponential backoff (1 s → 60 s capped). Re-attaches the audio engine if it was running prior to the loss.
- **`SessionEvent`** — broadcast enum emitted on connect/lost/reconnecting/disconnected/audio-started/audio-stopped/voice-state/chat/route changes. Subscribers consume via `subscribe_events()`.
- **File transfer** — avatar/icon download routed through a cacache-backed blob cache with LRU eviction.
- **Channel join state machine** — reducer-based state tracking for voice channel joins, with optimistic commands, snapshot reconciliation, and error projection.
- **PTT controller** — platform input backend management, binding persistence, and release-tail timer wiring (SDD-088/094/096).
## Public API Summary
### Core types
| Type | Role |
|---|---|
| `ChanoraSession` | Top-level session handle; cloneable, thread-safe |
| `CoreError` | Unified error enum covering all subsystem errors |
| `SessionEvent` | Broadcast lifecycle event enum |
| `ConnectConfig` | Typed connection parameters |
| `NetworkState` | OS connectivity state enum |
### Key methods on `ChanoraSession`
- `new()` — construct an empty session (no I/O)
- `init_storage(dir)` — wire identity + bookmark stores
- `init_cache(dir)` — wire the blob cache for avatars/icons
- `connect(cfg)``ServerSnapshot` — dial a server (single-connection invariant)
- `disconnect()` — clean teardown including supervisor
- `is_connected()` — check connection state
- `snapshot()``ServerSnapshot` — refresh server state
- `client_profile(client_id)` — rich profile for one client
- `voice_join(channel_id, password)` / `voice_leave()` — audio lifecycle
- `start_audio(cfg)` — initialize audio subsystem
- `set_input_device(id)` / `set_output_device(id)` — device selection
- `set_output_gain(gain)` / `set_client_volume(client_id, volume)` — volume control
- `set_transmit_mode(mode)` / `get_transmit_mode()` — transmit mode
- `set_hard_mute(muted)` — hard-mute clamp
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail config
- `set_ptt(active)` / `set_ptt_binding(binding)` / `ptt_descriptor()` — PTT control
- `send_text_message(message, target)` — chat
- `move_to_channel(id, password)` / `set_self_muted(input, output)` — channel + mute
- `subscribe_events()` — broadcast receiver for `SessionEvent`
- `drain_protocol_events()` / `protocol_events_snapshot()` — protocol event access
- `export_diagnostics()` — redacted diagnostic bundle (includes network stats)
- `audio_stats()` — audio subsystem telemetry
- `network_diagnostics_summary()` — network statistics
- `prefetch_server(host)` — warm server-address resolution
- `set_audio_processing_config(cfg)` / `get_audio_processing_config()` — audio DSP config
- `set_audio_debug_wav_dump(enabled)` — WAV dump toggle
- `set_vad_model_path(path)` — Silero model path
- `transmit_selector()` / `release_tail_timer()` — subsystem accessors
### Re-exports
Re-exports selected types from `chanora_protocol`, `chanora_audio`, `chanora_storage`, and `chanora_diagnostics` so the bridge only depends on `chanora_core`.
## Platform notes
- iOS/macOS-specific methods (`ios_handle_route_change`, `ios_handle_interruption_began`, etc.) are gated behind `cfg(target_os = "ios" | "macos")` inside method bodies.
- Android-specific reconnect paths are similarly gated.
- The crate itself compiles on all targets; platform-specific code is runtime- or cfg-gated.
## Invariants
- Single active connection at runtime (DEC-006)
- `tsclientlib` types never cross out of `chanora_protocol` (SAD-067)
- Secret material never lands in non-secret storage (DEC-013.2)
- Audio engine construction failure preserves the previous engine state
+519 -3
View File
@@ -1,5 +1,5 @@
use chanora_audio::{AudioRoute, PttBackendDescriptor};
use chanora_protocol::MessageTarget;
use chanora_protocol::{MessageTarget, PokeStrength};
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -22,6 +22,17 @@ impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
}
}
impl SessionEvent {
/// Construct a `PttCapability` event from an audio backend descriptor.
pub fn ptt_capability_from_descriptor(desc: &PttBackendDescriptor) -> Self {
Self::PttCapability {
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 {
@@ -139,6 +150,8 @@ pub enum SessionEvent {
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 {
@@ -233,7 +246,7 @@ pub enum SessionEvent {
}
/// Bridge-safe mirror of channel-join projection sync state.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
@@ -244,7 +257,7 @@ pub enum VoiceJoinSyncState {
}
/// Bridge-safe mirror of stable channel-join error codes.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
@@ -283,3 +296,506 @@ pub enum NetworkState {
/// OS reports no networks available.
Offline,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_state_equality() {
assert_eq!(NetworkState::Unknown, NetworkState::Unknown);
assert_eq!(NetworkState::Online, NetworkState::Online);
assert_eq!(NetworkState::Offline, NetworkState::Offline);
assert_ne!(NetworkState::Unknown, NetworkState::Online);
assert_ne!(NetworkState::Online, NetworkState::Offline);
}
#[test]
fn persisted_ptt_binding_empty() {
let binding = PersistedPttBinding::empty();
assert_eq!(binding.input_class, "");
assert_eq!(binding.key_label, "");
}
#[test]
fn persisted_ptt_binding_equality() {
let a = PersistedPttBinding {
input_class: "keyboard".to_string(),
key_label: "Space".to_string(),
};
let b = PersistedPttBinding {
input_class: "keyboard".to_string(),
key_label: "Space".to_string(),
};
assert_eq!(a, b);
}
#[test]
fn ptt_descriptor_snapshot_fields() {
let snap = PttDescriptorSnapshot {
level: "L0Focused".to_string(),
backend_id: "focused".to_string(),
bound_input_class: "keyboard".to_string(),
};
assert_eq!(snap.level, "L0Focused");
assert_eq!(snap.backend_id, "focused");
assert_eq!(snap.bound_input_class, "keyboard");
}
#[test]
fn session_event_connected() {
let evt = SessionEvent::Connected {
server_name: "Test Server".to_string(),
};
if let SessionEvent::Connected { server_name } = evt {
assert_eq!(server_name, "Test Server");
} else {
panic!("expected Connected variant");
}
}
#[test]
fn session_event_lost() {
let evt = SessionEvent::Lost {
reason: "timeout".to_string(),
};
if let SessionEvent::Lost { reason } = evt {
assert_eq!(reason, "timeout");
} else {
panic!("expected Lost variant");
}
}
#[test]
fn session_event_reconnecting() {
let evt = SessionEvent::Reconnecting {
attempt: 3,
delay_secs: 30,
};
if let SessionEvent::Reconnecting {
attempt,
delay_secs,
} = evt
{
assert_eq!(attempt, 3);
assert_eq!(delay_secs, 30);
} else {
panic!("expected Reconnecting variant");
}
}
#[test]
fn session_event_disconnected() {
let evt = SessionEvent::Disconnected {
reason: "user".to_string(),
};
if let SessionEvent::Disconnected { reason } = evt {
assert_eq!(reason, "user");
} else {
panic!("expected Disconnected variant");
}
}
#[test]
fn session_event_audio_started_stopped() {
let _ = SessionEvent::AudioStarted;
let _ = SessionEvent::AudioStopped;
}
#[test]
fn session_event_ptt_capability() {
let evt = SessionEvent::PttCapability {
level: "L1GlobalShortcut".to_string(),
backend_id: "global".to_string(),
bound_input_class: "keyboard".to_string(),
};
if let SessionEvent::PttCapability {
level,
backend_id,
bound_input_class,
} = evt
{
assert_eq!(level, "L1GlobalShortcut");
assert_eq!(backend_id, "global");
assert_eq!(bound_input_class, "keyboard");
} else {
panic!("expected PttCapability variant");
}
}
#[test]
fn session_event_voice_state() {
let evt = SessionEvent::VoiceState {
in_channel: true,
transmit_mode: 1,
mute: false,
release_tail_ms: 200,
current_channel_id: Some(42),
pending_target_channel_id: None,
can_join: false,
can_leave: true,
join_sync_state: VoiceJoinSyncState::Ready,
join_error_code: None,
};
if let SessionEvent::VoiceState {
in_channel,
transmit_mode,
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state,
join_error_code,
} = evt
{
assert!(in_channel);
assert_eq!(transmit_mode, 1);
assert!(!mute);
assert_eq!(release_tail_ms, 200);
assert_eq!(current_channel_id, Some(42));
assert_eq!(pending_target_channel_id, None);
assert!(!can_join);
assert!(can_leave);
assert_eq!(join_sync_state, VoiceJoinSyncState::Ready);
assert!(join_error_code.is_none());
} else {
panic!("expected VoiceState variant");
}
}
#[test]
fn session_event_interruption_state() {
let evt = SessionEvent::InterruptionState {
began: true,
should_resume: false,
};
if let SessionEvent::InterruptionState {
began,
should_resume,
} = evt
{
assert!(began);
assert!(!should_resume);
} else {
panic!("expected InterruptionState variant");
}
}
#[test]
fn session_event_chat_message() {
let evt = SessionEvent::ChatMessage {
sender_id: 5,
sender_name: "Alice".to_string(),
message: "Hello".to_string(),
target: chanora_protocol::MessageTarget::Channel,
poke_strength: None,
};
if let SessionEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
poke_strength,
} = evt
{
assert_eq!(sender_id, 5);
assert_eq!(sender_name, "Alice");
assert_eq!(message, "Hello");
assert_eq!(target, chanora_protocol::MessageTarget::Channel);
assert!(poke_strength.is_none());
} else {
panic!("expected ChatMessage variant");
}
}
#[test]
fn session_event_chat_message_with_poke() {
let evt = SessionEvent::ChatMessage {
sender_id: 3,
sender_name: "Bob".to_string(),
message: "".to_string(),
target: chanora_protocol::MessageTarget::Poke(7),
poke_strength: Some(chanora_protocol::PokeStrength::Suppressed),
};
if let SessionEvent::ChatMessage {
target,
poke_strength,
..
} = evt
{
assert_eq!(target, chanora_protocol::MessageTarget::Poke(7));
assert_eq!(poke_strength, Some(chanora_protocol::PokeStrength::Suppressed));
} else {
panic!("expected ChatMessage variant");
}
}
#[test]
fn session_event_server_activity() {
let evt = SessionEvent::ServerActivity {
message: "User joined channel".to_string(),
};
if let SessionEvent::ServerActivity { message } = &evt {
assert_eq!(message, "User joined channel");
} else {
panic!("expected ServerActivity variant");
}
}
#[test]
fn session_event_audio_route_changed() {
let evt = SessionEvent::AudioRouteChanged {
route: chanora_audio::AudioRoute::Speaker,
};
if let SessionEvent::AudioRouteChanged { route } = &evt {
assert_eq!(*route, chanora_audio::AudioRoute::Speaker);
} else {
panic!("expected AudioRouteChanged variant");
}
}
#[test]
fn session_event_client_moved() {
let evt = SessionEvent::ClientMoved {
client_id: 1,
new_channel_id: 2,
};
if let SessionEvent::ClientMoved {
client_id,
new_channel_id,
} = evt
{
assert_eq!(client_id, 1);
assert_eq!(new_channel_id, 2);
} else {
panic!("expected ClientMoved variant");
}
}
#[test]
fn session_event_client_joined() {
let evt = SessionEvent::ClientJoined {
client_id: 10,
channel_id: 3,
name: "NewUser".to_string(),
input_muted: false,
output_muted: true,
is_server_query: false,
talk_power: 0,
talk_power_granted: false,
};
if let SessionEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} = evt
{
assert_eq!(client_id, 10);
assert_eq!(channel_id, 3);
assert_eq!(name, "NewUser");
assert!(!input_muted);
assert!(output_muted);
assert!(!is_server_query);
assert_eq!(talk_power, 0);
assert!(!talk_power_granted);
} else {
panic!("expected ClientJoined variant");
}
}
#[test]
fn session_event_client_left() {
let evt = SessionEvent::ClientLeft {
client_id: 10,
name: "Departing".to_string(),
};
if let SessionEvent::ClientLeft { client_id, name } = evt {
assert_eq!(client_id, 10);
assert_eq!(name, "Departing");
} else {
panic!("expected ClientLeft variant");
}
}
#[test]
fn session_event_client_updated() {
let evt = SessionEvent::ClientUpdated {
client_id: 5,
input_muted: true,
output_muted: false,
is_server_query: true,
talk_power: 75,
talk_power_granted: true,
};
if let SessionEvent::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} = evt
{
assert_eq!(client_id, 5);
assert!(input_muted);
assert!(!output_muted);
assert!(is_server_query);
assert_eq!(talk_power, 75);
assert!(talk_power_granted);
} else {
panic!("expected ClientUpdated variant");
}
}
#[test]
fn session_event_channel_added() {
let evt = SessionEvent::ChannelAdded {
id: 7,
parent: 1,
name: "Sub".to_string(),
order: 3,
has_password: true,
needed_talk_power: Some(50),
};
if let SessionEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} = evt
{
assert_eq!(id, 7);
assert_eq!(parent, 1);
assert_eq!(name, "Sub");
assert_eq!(order, 3);
assert!(has_password);
assert_eq!(needed_talk_power, Some(50));
} else {
panic!("expected ChannelAdded variant");
}
}
#[test]
fn session_event_channel_removed() {
let evt = SessionEvent::ChannelRemoved { id: 7 };
if let SessionEvent::ChannelRemoved { id } = evt {
assert_eq!(id, 7);
} else {
panic!("expected ChannelRemoved variant");
}
}
#[test]
fn session_event_channel_updated() {
let evt = SessionEvent::ChannelUpdated {
id: 7,
name: "Renamed".to_string(),
has_password: false,
needed_talk_power: None,
};
if let SessionEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} = evt
{
assert_eq!(id, 7);
assert_eq!(name, "Renamed");
assert!(!has_password);
assert!(needed_talk_power.is_none());
} else {
panic!("expected ChannelUpdated variant");
}
}
#[test]
fn voice_join_sync_state_variants() {
let ready = VoiceJoinSyncState::Ready;
let init = VoiceJoinSyncState::SynchronizingInitialSnapshot;
let reconnect = VoiceJoinSyncState::SynchronizingReconnect;
assert_ne!(
std::mem::discriminant(&ready),
std::mem::discriminant(&init)
);
assert_ne!(
std::mem::discriminant(&init),
std::mem::discriminant(&reconnect)
);
}
#[test]
fn voice_join_error_code_all_variants() {
let codes = [
VoiceJoinErrorCode::DuplicateSameTargetCoalesced,
VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget,
VoiceJoinErrorCode::JoinDenied,
VoiceJoinErrorCode::JoinProtocolFailure,
VoiceJoinErrorCode::JoinNetworkFailure,
VoiceJoinErrorCode::JoinTimeout,
VoiceJoinErrorCode::JoinSupersededByLeave,
VoiceJoinErrorCode::JoinStaleOutcomeIgnored,
VoiceJoinErrorCode::JoinReconciledDifferentChannel,
VoiceJoinErrorCode::JoinCommandRejectedBeforeSend,
VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing,
];
for i in 0..codes.len() {
for j in 0..codes.len() {
if i == j {
assert_eq!(
std::mem::discriminant(&codes[i]),
std::mem::discriminant(&codes[j])
);
} else {
assert_ne!(
std::mem::discriminant(&codes[i]),
std::mem::discriminant(&codes[j])
);
}
}
}
}
#[test]
fn session_event_clone_preserves_fields() {
let evt = SessionEvent::Connected {
server_name: "Cloneable".to_string(),
};
let cloned = evt.clone();
if let SessionEvent::Connected { server_name } = cloned {
assert_eq!(server_name, "Cloneable");
} else {
panic!("expected Connected variant after clone");
}
}
#[test]
fn session_event_voice_state_with_join_error() {
let evt = SessionEvent::VoiceState {
in_channel: false,
transmit_mode: 0,
mute: false,
release_tail_ms: 200,
current_channel_id: None,
pending_target_channel_id: None,
can_join: true,
can_leave: false,
join_sync_state: VoiceJoinSyncState::Ready,
join_error_code: Some(VoiceJoinErrorCode::JoinDenied),
};
if let SessionEvent::VoiceState { join_error_code, .. } = evt {
assert_eq!(join_error_code, Some(VoiceJoinErrorCode::JoinDenied));
} else {
panic!("expected VoiceState variant");
}
}
}
+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);
}
}
+215 -65
View File
@@ -53,6 +53,7 @@ use chanora_state::channel_join::{
};
mod events;
mod file_transfer;
mod network_diagnostics;
pub mod ptt;
@@ -67,14 +68,15 @@ pub use chanora_diagnostics::{
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
};
pub use chanora_protocol::{
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
validate_nickname, ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig,
DisconnectReason, MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
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.
@@ -92,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),
@@ -130,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
@@ -174,6 +181,10 @@ fn normalize_channel_password(password: Option<String>) -> Option<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)]
@@ -193,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,
@@ -248,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,
@@ -269,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`]
@@ -333,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> {
@@ -510,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,
@@ -564,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),
@@ -627,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
@@ -657,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.
@@ -673,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(())
}
@@ -737,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(
@@ -829,21 +924,13 @@ impl ChanoraSession {
// dialog) re-publish through the controller's
// descriptor-watch.
let initial_desc = controller.descriptor().await;
let _ = self.events_tx.send(SessionEvent::PttCapability {
level: initial_desc.level.as_str().to_string(),
backend_id: initial_desc.backend_id.to_string(),
bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(),
});
let _ = self.events_tx.send(SessionEvent::ptt_capability_from_descriptor(&initial_desc));
let mut watch_rx = controller.descriptor_watch();
let events_tx = self.events_tx.clone();
tokio::spawn(async move {
while watch_rx.changed().await.is_ok() {
let d = watch_rx.borrow_and_update().clone();
let _ = events_tx.send(SessionEvent::PttCapability {
level: d.level.as_str().to_string(),
backend_id: d.backend_id.to_string(),
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
});
let _ = events_tx.send(SessionEvent::ptt_capability_from_descriptor(&d));
}
});
Ok(())
@@ -905,11 +992,7 @@ impl ChanoraSession {
if let Some(state) = guard.as_ref() {
if let Some(controller) = state.ptt_controller.as_ref() {
let desc = controller.set_binding(binding).await?;
let _ = self.events_tx.send(SessionEvent::PttCapability {
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(),
});
let _ = self.events_tx.send(SessionEvent::ptt_capability_from_descriptor(&desc));
}
}
Ok(())
@@ -972,10 +1055,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);
}
@@ -1000,7 +1084,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);
@@ -1025,6 +1113,21 @@ impl ChanoraSession {
Ok(())
}
/// Read the current master output gain. Returns `1.0` (unity) if
/// audio is not started.
pub async fn output_gain(&self) -> f32 {
let guard = self.inner.lock().await;
let state = match guard.as_ref() {
Some(s) => s,
None => return 1.0,
};
let audio = match state.audio.as_ref() {
Some(a) => a,
None => return 1.0,
};
audio.output_gain()
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op if audio is not started or client has no active
/// voice queue.
@@ -1112,11 +1215,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(())
}
@@ -1315,11 +1426,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
@@ -1581,7 +1693,9 @@ 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() {
@@ -1643,6 +1757,7 @@ async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration:
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>,
@@ -1674,6 +1789,7 @@ fn spawn_event_forwarders(
sender_name: msg.sender_name,
message: msg.message,
target: msg.target,
poke_strength: msg.poke_strength,
});
}
});
@@ -1776,6 +1892,7 @@ fn spawn_event_forwarders(
async fn supervisor_loop(ctx: SupervisorContext) {
let SupervisorContext {
state_arc,
protocol,
events_tx,
initial_cfg,
initial_lost_rx,
@@ -2045,20 +2162,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,
@@ -2088,9 +2206,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);
}
}
@@ -2102,8 +2220,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,
);
@@ -2147,14 +2272,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
// capability (SRS-196 / SDD-091).
let d = controller.descriptor().await;
let _ =
events_tx.send(SessionEvent::PttCapability {
level: d.level.as_str().to_string(),
backend_id: d.backend_id.to_string(),
bound_input_class: d
.bound_input_class
.unwrap_or("")
.to_string(),
});
events_tx.send(SessionEvent::ptt_capability_from_descriptor(&d));
}
Err(e) => {
warn!(
@@ -2534,6 +2652,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();
+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]
+66
View File
@@ -0,0 +1,66 @@
# chanora_audio
Real-time audio subsystem: capture, Opus encoding/decoding, voice rendering, PTT gating, and audio processing. Promoted from `poc/audio-capture-playback-spike`.
## Architecture
### Engine
- **`AudioEngine`** — the primary type. Starts a platform audio backend (capture + playback), wires an `AudioTransmitGate` for PTT gating, and feeds encoded Opus frames to the protocol layer via `voice_out`. Inbound voice packets are decoded and mixed by `tsclientlib::audio::AudioHandler` and pulled by the platform output callback at 48 kHz stereo.
### Platform backends (cfg-gated)
| Target | Backend | Notes |
|---|---|---|
| Android | Oboe (via `android_voice_unit`) | Requires `ndk_context` before start |
| iOS/macOS | Apple VoiceProcessingIO (`ios_voice_unit`) | Platform AEC/AGC/NS, route-change handling |
| Linux | SDL (`sdl_output`) | PulseAudio/ALSA via SDL |
| Other desktop | cpal | Fallback |
### Key modules
- **`audio_processing`** — P1 audio processing config, stats, route policy, effect ownership (Platform/Sonora/WebRTC APM)
- **`opus_voice`** — 20 ms / 48 kHz mono Opus encode/decode via `audiopus`
- **`transmit_mode`** — `TransmitMode` enum: Ptt, Continuous, VoiceActivity
- **`transmit_selector`** — `TransmitModeSelector` combining mode, hard-mute, PTT gate, permission gate, and in-channel state
- **`ptt`** — `AudioTransmitGate` (atomic bool), `PttCapabilityLevel`, `PttBackendDescriptor`
- **`ptt_backends`** — platform PTT backends: `DesktopPttBackend` (Linux portal), `FocusedPttBackend` (in-app fallback)
- **`release_tail`** — `ReleaseTailTimer` for configurable PTT release delay (default 200 ms, max 500 ms)
- **`vad`** — Voice-activity detection: Silero ONNX (desktop), WebRTC fallback, energy debug
- **`voice_render`** — mixes per-client decoded f32 PCM into the output buffer
- **`debug_wav`** — optional WAV file dump for diagnostics (DIAG_002/003)
- **`mobile_voice_backend`** — shared mobile voice-unit lifecycle abstraction
- **`frame`** — frame-aligned buffer utilities
## Public API Summary
### Types
| Type | Role |
|---|---|
| `AudioEngine` | Start/stop audio, set gain/mute/volume, read stats |
| `AudioEngineConfig` | Capture/playback device selection, PTT initial state, processing config |
| `AudioDeviceInfo` / `AudioDeviceList` | Device enumeration |
| `AudioTransmitGate` | Atomic PTT gate |
| `TransmitMode` / `TransmitModeSelector` | Mode selection with hard-mute clamp |
| `ReleaseTailTimer` | Configurable release delay (SDD-096) |
| `PttBinding` / `PttInputClass` | PTT key binding types |
| `PttBackendDescriptor` / `PttCapabilityLevel` | Capability query |
| `AudioProcessingConfig` / `AudioProcessingStats` | P1 processing control and telemetry |
| `AudioRoute` | Speaker/Earpiece/Wired/Bluetooth enum |
| `AudioEffects` | Effect toggles (AEC/AGC/NS/HPF), all enabled by default (DEC-007..010) |
| `AudioError` | Typed error catalogue |
### Key functions
- `AudioEngine::start_with_gate(cfg, voice_out, voice_in, gate)` — construct and start
- `AudioEngine::stop()` — tear down
- `list_audio_devices()` — enumerate available input/output devices
- `select_ptt_backend()` — choose the best PTT backend for the current platform
## Platform notes
- Android requires `initChanoraContext` (NDK context) before engine start.
- iOS/macOS uses VoiceProcessingIO for platform AEC/AGC/NS in the default route.
- Desktop can use Silero ONNX VAD when the model file is available.
- `bench_seam` is exposed (`#[doc(hidden)]`) for criterion benchmarks on non-mobile targets.
+3 -18
View File
@@ -375,16 +375,7 @@ impl AndroidCaptureState {
speech: true,
}
} else if vad_backend == crate::VadBackend::SileroOnnx {
match crate::vad::callback_vad_worker_policy(
voice_activity_mode,
vad_backend,
self.silero_vad_worker.is_some(),
) {
crate::vad::VadWorkerPolicy::UseWorker => {
let worker = self
.silero_vad_worker
.as_ref()
.expect("policy checked worker");
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
@@ -405,8 +396,7 @@ impl AndroidCaptureState {
&frame,
)
}
}
crate::vad::VadWorkerPolicy::UseFallback => {
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
@@ -414,11 +404,6 @@ impl AndroidCaptureState {
&frame,
)
}
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
probability: 1.0,
speech: true,
},
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
@@ -870,7 +855,7 @@ impl AndroidVoiceUnit {
// by the capture callback's WebRtcApmProcessor.
{
use crate::audio_processing::EffectOwner;
let mut apm_cfg = apm_config_clone.lock().unwrap();
let mut apm_cfg = apm_config_clone.lock().unwrap_or_else(|e| e.into_inner());
let hw_aec = hw_effects.aec.is_some();
let hw_ns = hw_effects.ns.is_some();
let hw_agc = hw_effects.agc.is_some();
@@ -24,8 +24,8 @@ pub enum AudioCommand {
/// Set a client's output volume.
SetVolume(SessionAudioId, f32),
/// Remove a client's decode queue.
// TODO: Wire to client disconnect path; handled in callback but no
// producer currently pushes this command.
// TRACKED(TODO-005): Wire to client disconnect path; handled in callback
// but no producer currently pushes this command.
#[allow(dead_code)]
RemoveClient(SessionAudioId),
}
+5 -68
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,42 +193,23 @@ 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(())
}
/// Demote a failed VAD backend to the WebRTC fallback.
///
/// Returns `true` when the config changed.
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
self.vad_backend = VadBackend::WebrtcVad;
true
} else {
false
}
}
}
#[cfg(test)]
@@ -261,46 +240,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 {
vad_backend: VadBackend::SileroOnnx,
..AudioProcessingConfig::default()
};
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
}
}
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
@@ -404,10 +343,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);
}
-9
View File
@@ -314,15 +314,6 @@ mod tests {
rec.stop();
}
#[test]
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
let src = include_str!("ios_raw_unit.rs");
assert!(
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
);
}
#[test]
fn wav_header_is_44_bytes() {
// Write to a temp file to test the header.
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use cpal::traits::StreamTrait;
use cpal::{SampleFormat, SizedSample};
use tracing::{debug, error, warn, info};
use chanora_protocol::OutPacket;
use audiopus::coder::Encoder as OpusEncoder;
use crate::AudioError;
use super::SAMPLE_RATE;
use super::FRAME_SAMPLES;
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
pub(super) fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: tokio::sync::mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<cpal::Stream, AudioError> {
let in_cfg = in_dev
.default_input_config()
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
let in_sample_rate = in_cfg.sample_rate();
let in_channels = in_cfg.channels() as usize;
let in_format = in_cfg.sample_format();
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
}
#[cfg(not(target_os = "windows"))]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
}
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
let capture_state = Arc::new(Mutex::new(CaptureState::new(
opus_enc,
in_sample_rate,
in_channels,
mic_gain,
crate::opus_voice::start_out_packet_worker(
voice_out_tx,
frames_sent.clone(),
"cpal-capture",
)?,
transmit_active,
voice_activity_selector,
audio_processing_config,
silero_vad_worker,
audio_processing_stats,
)));
let stream = match in_format {
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
other => {
return Err(AudioError::StreamConfig(format!(
"unsupported input format: {other:?}"
)))
}
};
Ok(stream)
}
pub(super) struct CaptureState {
encoder: OpusEncoder,
pub(super) pcm_accum: Vec<f32>,
pub(super) pending_10ms: [f32; crate::frame::FRAME_10MS_SAMPLES],
pub(super) pending_10ms_len: usize,
pub(super) capture_frame_seq: u64,
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
resample_pos: f64,
resample_last: f32,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
silero_model_epoch: u64,
current_vad_backend: crate::VadBackend,
fallback_warned_backend: Option<crate::VadBackend>,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
mono_scratch: Vec<f32>,
frame_scratch: Vec<f32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
last_level_emit: std::time::Instant,
}
impl CaptureState {
pub(super) fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Self {
Self {
encoder,
in_sample_rate,
in_channels,
mic_gain,
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
resample_pos: 0.0,
resample_last: 0.0,
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker,
silero_model_epoch: crate::vad::silero_model_epoch(),
current_vad_backend: crate::VadBackend::Disabled,
fallback_warned_backend: None,
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
pending_10ms: [0.0; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
mono_scratch: Vec::with_capacity(4096),
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
last_level_emit: std::time::Instant::now()
.checked_sub(LEVEL_METER_INTERVAL)
.unwrap_or_else(std::time::Instant::now),
}
}
pub(super) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
let in_channels = self.in_channels;
let mic_gain = self.mic_gain;
self.mono_scratch.clear();
let frame_count = buf.len() / in_channels.max(1);
self.mono_scratch.reserve(frame_count);
for frame in buf.chunks(in_channels) {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
self.mono_scratch.push(sum / frame.len() as f32);
}
let now = std::time::Instant::now();
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
self.last_level_emit = now;
self.audio_processing_stats
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
}
if mic_gain != 1.0 {
for s in &mut self.mono_scratch {
*s *= mic_gain;
}
}
let vad_start_offset = self.pcm_accum.len();
if self.in_sample_rate == SAMPLE_RATE {
let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum);
dst.extend_from_slice(src);
} else {
let mono = std::mem::take(&mut self.mono_scratch);
self.resample_into_accum(&mono);
self.mono_scratch = mono;
}
self.process_pending_vad_frames(vad_start_offset);
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
}
while self.pcm_accum.len() >= FRAME_SAMPLES {
let frame = &mut self.frame_scratch;
frame.clear();
frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES));
for s in frame.iter_mut() {
if *s > 1.0 {
*s = 1.0;
} else if *s < -1.0 {
*s = -1.0;
}
}
match self
.encoder
.encode_float(&frame[..], &mut self.opus_out[..])
{
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.opus_out,
len,
|| {
warn!(
target: "chanora_audio",
"voice_out queue full; dropping frame"
);
},
|| {
warn!(target: "chanora_audio", "voice_out closed; stopping send");
},
);
}
Err(e) => {
error!(target: "chanora_audio", error = %e, "opus encode failed");
}
}
}
}
pub(super) fn process_pending_vad_frames(&mut self, start_offset: usize) {
let mut offset = start_offset.min(self.pcm_accum.len());
while offset < self.pcm_accum.len() {
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(self.pcm_accum.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&self.pcm_accum[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;
}
}
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
self.fallback_warned_backend = Some(failed_backend);
}
fn sync_vad_backend(&mut self, voice_activity_mode: bool, vad_backend: crate::VadBackend) {
if !voice_activity_mode {
self.current_vad_backend = crate::VadBackend::Disabled;
self.fallback_warned_backend = None;
self.audio_processing_stats.set_vad_fallback_active(false);
return;
}
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed =
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
if vad_backend == self.current_vad_backend && !silero_changed {
return;
}
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.fallback_warned_backend = None;
self.vad_state.reset();
match vad_backend {
crate::VadBackend::SileroOnnx => {
let worker_available = self
.silero_vad_worker
.try_lock()
.map(|worker| worker.is_some())
.unwrap_or(false);
if worker_available {
self.audio_processing_stats.set_vad_fallback_active(false);
} else {
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
}
}
crate::VadBackend::WebrtcVad => {
self.audio_processing_stats.set_vad_fallback_active(false);
}
crate::VadBackend::EnergyDebug => {
self.audio_processing_stats.set_vad_fallback_active(true);
}
crate::VadBackend::Disabled => {
self.audio_processing_stats.set_vad_fallback_active(false);
}
}
}
pub(super) fn process_10ms_capture_frame(&mut self, frame: &[f32; crate::frame::FRAME_10MS_SAMPLES]) {
let input_dbfs = crate::frame::dbfs(frame);
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,
));
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.sync_vad_backend(true, vad_backend);
self.vad_state.configure(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
} else {
self.sync_vad_backend(false, vad_backend);
}
let (vad_probability, gate_open, used_fallback_vad) = 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 = match vad_backend {
crate::VadBackend::Disabled => crate::vad::VadOutput {
probability: 1.0,
speech: true,
},
crate::VadBackend::SileroOnnx => {
let worker_output = {
let guard = self.silero_vad_worker.try_lock().ok();
guard.and_then(|guard| {
let worker = guard.as_ref()?;
if worker.try_send(capture_seq, frame) && !worker.is_stale(capture_seq)
{
let p = worker.latest_probability();
Some(crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
})
} else {
None
}
})
};
if let Some(output) = worker_output {
output
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
frame,
)
}
}
crate::VadBackend::WebrtcVad | crate::VadBackend::EnergyDebug => {
used_fallback_vad = vad_backend == crate::VadBackend::EnergyDebug;
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, frame)
}
};
(
vad.probability,
self.vad_state.update(vad.speech),
used_fallback_vad,
)
} else {
(0.0, false, false)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
let vad_active = voice_activity_mode && gate_open;
if let Some(selector) = &self.voice_activity_selector {
selector.set_voice_activity_open(vad_active);
}
self.audio_processing_stats.update_capture(
input_dbfs,
input_dbfs,
vad_probability,
vad_active,
self.transmit_active.load(Ordering::Relaxed),
);
self.audio_processing_stats
.record_capture_frame(frame.iter().all(|sample| sample.abs() <= 0.000_001));
}
fn resample_into_accum(&mut self, mono: &[f32]) {
if mono.is_empty() {
return;
}
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
let mut pos = self.resample_pos;
while pos < mono.len() as f64 {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let a = if i <= 0 {
self.resample_last
} else {
mono[(i - 1) as usize]
};
let b = if i < mono.len() as isize {
mono[i as usize]
} else {
a
};
self.pcm_accum
.push((a as f64 + frac * (b - a) as f64) as f32);
pos += ratio;
}
self.resample_pos = pos - mono.len() as f64;
self.resample_last = *mono.last().unwrap();
}
}
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
impl ToF32 for f32 {
fn to_f32_sample(self) -> f32 {
self
}
}
impl ToF32 for i16 {
fn to_f32_sample(self) -> f32 {
f32::from(self) / f32::from(i16::MAX)
}
}
impl ToF32 for u16 {
fn to_f32_sample(self) -> f32 {
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
}
}
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
state: Arc<Mutex<CaptureState>>,
) -> Result<cpal::Stream, AudioError>
where
T: SizedSample + ToF32 + Send + 'static,
{
let stream = device
.build_input_stream(
*config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
s.ingest(data);
},
move |e| {
error!(target: "chanora_audio", error = %e, "input stream error");
},
None,
)
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
Ok(stream)
}
#[doc(hidden)]
pub mod bench_seam {
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
use tokio::sync::mpsc;
pub struct CaptureBenchHandle {
pub(crate) state: CaptureState,
_rx: mpsc::Receiver<OutPacket>,
transmit_active: Arc<AtomicBool>,
}
impl CaptureBenchHandle {
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
let encoder =
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
let (tx, rx) = mpsc::channel::<OutPacket>(64);
let transmit_active = Arc::new(AtomicBool::new(true));
let frames_sent = Arc::new(AtomicU32::new(0));
let voice_out_tx =
crate::opus_voice::start_out_packet_worker(tx, frames_sent, "cpal-bench")
.expect("start_out_packet_worker");
let state = CaptureState::new(
encoder,
in_sample_rate,
in_channels,
1.0,
voice_out_tx,
transmit_active.clone(),
None,
Arc::new(std::sync::Mutex::new(
crate::AudioProcessingConfig::default(),
)),
Arc::new(std::sync::Mutex::new(None)),
Arc::new(crate::SharedAudioProcessingStats::default()),
);
Self {
state,
_rx: rx,
transmit_active,
}
}
#[inline]
pub fn ingest_f32(&mut self, buf: &[f32]) {
self.state.ingest(buf);
}
pub fn set_transmit_active(&self, active: bool) {
self.transmit_active
.store(active, std::sync::atomic::Ordering::Relaxed);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use cpal::traits::StreamTrait;
use cpal::SampleFormat;
use tracing::{error, warn};
use tsclientlib::audio::AudioHandler;
use crate::AudioError;
use super::SessionAudioId;
use super::SAMPLE_RATE;
pub(super) fn build_output_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
dev_sample_rate: u32,
dev_channels: usize,
) -> Result<cpal::Stream, AudioError>
where
T: cpal::SizedSample + FromF32 + Send + 'static,
{
let resample_ratio = SAMPLE_RATE as f64 / dev_sample_rate as f64;
let same_rate = dev_sample_rate == SAMPLE_RATE;
let resample_state: Arc<Mutex<PlaybackResampleState>> =
Arc::new(Mutex::new(PlaybackResampleState {
pos: 0.0,
last_l: 0.0,
last_r: 0.0,
}));
let mut scratch: Vec<f32> = Vec::with_capacity(8192);
let mut last_slow_warn = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(2))
.unwrap_or_else(std::time::Instant::now);
let stream = device
.build_output_stream(
*config,
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
let cb_start = std::time::Instant::now();
let muted = output_muted.load(Ordering::Relaxed);
let dev_frames = out.len() / dev_channels.max(1);
let src_frames = if same_rate {
dev_frames
} else {
((dev_frames as f64 * resample_ratio).ceil() as usize) + 2
};
let needed = src_frames * 2;
if scratch.len() < needed {
scratch.resize(needed, 0.0);
}
scratch[..needed].fill(0.0);
{
let mut h = handler.lock().unwrap_or_else(|e| e.into_inner());
h.fill_buffer(&mut scratch[..needed]);
}
if muted {
for dst in out.iter_mut() {
*dst = T::from_f32_sample(0.0);
}
} else {
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
if same_rate && dev_channels == 2 {
for (dst, s) in out.iter_mut().zip(scratch[..needed].iter().copied()) {
*dst = T::from_f32_sample(s * gain);
}
} else {
let mut state = resample_state.lock().unwrap_or_else(|e| e.into_inner());
let mut pos = state.pos;
let mut last_l = state.last_l;
let mut last_r = state.last_r;
for frame_idx in 0..dev_frames {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let (a_l, a_r) = if i <= 0 {
(last_l, last_r)
} else {
let idx = ((i - 1) as usize) * 2;
(scratch[idx], scratch[idx + 1])
};
let i_usize = i.max(0) as usize;
let (b_l, b_r) = if i_usize < src_frames {
let idx = i_usize * 2;
(scratch[idx], scratch[idx + 1])
} else {
(a_l, a_r)
};
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
let base = frame_idx * dev_channels;
if dev_channels == 1 {
out[base] = T::from_f32_sample((l + r) * 0.5);
} else {
out[base] = T::from_f32_sample(l);
if dev_channels >= 2 {
out[base + 1] = T::from_f32_sample(r);
}
for c in 2..dev_channels {
out[base + c] = T::from_f32_sample(0.0);
}
}
pos += resample_ratio;
}
let consumed = pos.floor() as usize;
state.pos = pos - consumed as f64;
if consumed > 0 && consumed <= src_frames {
let idx = (consumed - 1) * 2;
last_l = scratch[idx];
last_r = scratch[idx + 1];
state.last_l = last_l;
state.last_r = last_r;
}
}
}
let elapsed = cb_start.elapsed();
let period_us = (dev_frames as u64 * 1_000_000) / dev_sample_rate as u64;
if elapsed.as_micros() as u64 > period_us / 2
&& last_slow_warn.elapsed() > std::time::Duration::from_secs(1)
{
last_slow_warn = std::time::Instant::now();
warn!(
target: "chanora_audio",
callback_us = elapsed.as_micros() as u64,
period_us,
dev_frames,
"output callback exceeded half the period budget — possible underrun cause"
);
}
},
move |e| {
error!(target: "chanora_audio", error = %e, "output stream error");
},
None,
)
.map_err(|e| {
error!(
target: "chanora_audio",
error = %e,
requested_channels = config.channels,
requested_sample_rate = config.sample_rate,
"build_output_stream FAILED"
);
AudioError::Backend(format!("build_output_stream: {e}"))
})?;
Ok(stream)
}
struct PlaybackResampleState {
pos: f64,
last_l: f32,
last_r: f32,
}
pub(super) trait FromF32 {
fn from_f32_sample(v: f32) -> Self;
}
impl FromF32 for f32 {
fn from_f32_sample(v: f32) -> Self {
v
}
}
impl FromF32 for i16 {
fn from_f32_sample(v: f32) -> Self {
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
}
}
impl FromF32 for u16 {
fn from_f32_sample(v: f32) -> Self {
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
(s + i32::from(i16::MAX) + 1) as u16
}
}
+2 -60
View File
@@ -16,56 +16,12 @@ pub const FRAME_10MS_SAMPLES: usize = 480;
/// Samples in one 20 ms mono frame at 48 kHz.
pub const FRAME_20MS_SAMPLES: usize = 960;
/// 10 ms, 48 kHz, mono f32 processing frame.
#[derive(Debug, Clone, PartialEq)]
pub struct AudioFrame10ms {
/// Samples normalized to `[-1.0, 1.0]`.
pub samples: [f32; FRAME_10MS_SAMPLES],
}
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
#[derive(Debug, Clone, PartialEq)]
pub struct AudioFrame20ms {
/// Samples normalized to `[-1.0, 1.0]`.
pub samples: [f32; FRAME_20MS_SAMPLES],
}
impl AudioFrame20ms {
/// Convert one 20 ms frame into two 10 ms processing frames.
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
let mut first = [0.0; FRAME_10MS_SAMPLES];
let mut second = [0.0; FRAME_10MS_SAMPLES];
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
(
AudioFrame10ms { samples: first },
AudioFrame10ms { samples: second },
)
}
}
impl AudioFrame10ms {
/// Merge two 10 ms processing frames back into the 20 ms network
/// cadence used by the existing Opus path.
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
let mut samples = [0.0; FRAME_20MS_SAMPLES];
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
AudioFrame20ms { samples }
}
/// Compute RMS dBFS for diagnostics and fallback VAD.
pub fn dbfs(&self) -> f32 {
dbfs(&self.samples)
}
}
/// Convert i16 PCM to normalized f32 PCM.
/// Convert i16 PCM sample to normalized f32 PCM (-1.0 to 1.0).
pub fn i16_to_f32(sample: i16) -> f32 {
sample as f32 / i16::MAX as f32
}
/// Convert normalized f32 PCM to saturated i16 PCM.
/// Convert normalized f32 PCM to saturated i16 PCM (clamps to [-1.0, 1.0]).
pub fn f32_to_i16(sample: f32) -> i16 {
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
@@ -84,18 +40,4 @@ pub fn dbfs(samples: &[f32]) -> f32 {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_merge_preserves_samples() {
let mut samples = [0.0; FRAME_20MS_SAMPLES];
for (i, s) in samples.iter_mut().enumerate() {
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
}
let original = AudioFrame20ms { samples };
let (a, b) = original.split();
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
}
}
-538
View File
@@ -1,538 +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 tracing::{info, warn};
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::processor::AudioProcessor;
use crate::AudioError;
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.
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
type RenderReferenceFrameAccumulator =
crate::render_reference::RenderReferenceFrameAccumulator<480>;
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
// ------------------------------------------------------------------ //
// Capture pipeline state //
// ------------------------------------------------------------------ //
struct RawCaptureState {
encoder: OpusEncoder,
pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
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>,
}
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: crate::opus_voice::start_out_packet_worker(
params.voice_out_tx.clone(),
params.frames_sent.clone(),
"ios-raw",
)?,
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.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,
})
}
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.encode_complete_20ms_frames();
self.pending_10ms_len = 0;
}
}
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
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]);
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.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);
// Debug WAV mic taps are intentionally unavailable on iOS raw
// realtime callbacks until WavDebugRecorder supports a
// preallocated handoff path; the current recorder push path
// allocates per 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);
// Processed-mic debug WAV capture is disabled for the same
// realtime allocation reason as the raw-mic tap above.
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 = None;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
} 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 {
match crate::vad::callback_vad_worker_policy(
voice_activity_mode,
vad_backend,
self.silero_coreml_worker.is_some(),
) {
crate::vad::VadWorkerPolicy::UseWorker => {
let worker = self
.silero_coreml_worker
.as_ref()
.expect("policy checked worker");
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,
)
}
}
crate::vad::VadWorkerPolicy::UseFallback => {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
probability: 1.0,
speech: true,
},
}
} 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;
}
if crate::capture_accumulator::append_processed_i16_bounded(
&mut self.pcm_accum,
&frame,
self.mic_gain,
) {
self.audio_processing_stats.increment_callback_xrun();
}
}
}
// ------------------------------------------------------------------ //
// 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 = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
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 process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
let stereo_n = process_n * 2;
if n > RAW_RENDER_SCRATCH_FRAMES {
stats_render.increment_callback_xrun();
}
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.
crate::voice_render::downmix_stereo_f32_to_mono_f32(
&scratch[..stereo_n],
&mut mono[..process_n],
);
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
render_ref_buf.write(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_interleaved_i16(
&scratch[..stereo_n],
&mut out[..process_n],
1,
gain,
muted,
);
if process_n < n {
out[process_n..].fill(0);
}
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");
}
}
}
}
+6 -21
View File
@@ -406,16 +406,7 @@ impl IosCaptureState {
speech: true,
}
} else if vad_backend == crate::VadBackend::SileroOnnx {
match crate::vad::callback_vad_worker_policy(
voice_activity_mode,
vad_backend,
self.silero_coreml_worker.is_some(),
) {
crate::vad::VadWorkerPolicy::UseWorker => {
let worker = self
.silero_coreml_worker
.as_ref()
.expect("policy checked worker");
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();
@@ -436,8 +427,7 @@ impl IosCaptureState {
&frame,
)
}
}
crate::vad::VadWorkerPolicy::UseFallback => {
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
crate::vad::VoiceActivityDetector::process_10ms(
@@ -445,11 +435,6 @@ impl IosCaptureState {
&frame,
)
}
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
probability: 1.0,
speech: true,
},
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
@@ -564,7 +549,7 @@ impl IosVoiceUnit {
let unit_arc2 = Arc::clone(&unit_arc);
dispatch2::DispatchQueue::main().exec_async(move || {
let mut guard = unit_arc2.lock().unwrap();
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
let unit = guard.as_mut().unwrap();
let _ = tx.send(op(unit));
});
@@ -574,7 +559,7 @@ impl IosVoiceUnit {
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
};
self.unit = unit_arc.lock().unwrap().take();
self.unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take();
result.map_err(AudioError::Backend)
}
@@ -1087,7 +1072,7 @@ impl IosVoiceUnit {
let unit_arc2 = unit_arc.clone();
dispatch2::DispatchQueue::main().exec_async(move || {
let mut guard = unit_arc2.lock().unwrap();
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
let u = guard.as_mut().unwrap();
let result = u
.initialize()
@@ -1109,7 +1094,7 @@ impl IosVoiceUnit {
}
}
unit = unit_arc.lock().unwrap().take().unwrap();
unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take().unwrap();
}
info!(
+1 -4
View File
@@ -65,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;
@@ -13,7 +13,7 @@
//! `cpal` (and SDL on Linux) own desktop capture/playback per the
//! existing audio engine design.
// TODO(SDD-117): back-fill `IosVoiceUnit` to implement this trait
// TRACKED(SDD-117): back-fill `IosVoiceUnit` to implement this trait
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
// across iOS and Android.
@@ -543,7 +543,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
}
let runloop = unsafe { CFRunLoopGetCurrent() };
{
let mut g = worker_runloop.lock().unwrap();
let mut g = worker_runloop.lock().unwrap_or_else(|e| e.into_inner());
*g = Some(RunLoopHandle(runloop));
}
unsafe {
@@ -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
+1 -1
View File
@@ -174,7 +174,7 @@ impl AudioCallback for TsPlaybackCallback {
// in the cpal path, but the upstream design has shipped
// this way for years.
{
let mut data = self.handler.lock().unwrap();
let mut data = self.handler.lock().unwrap_or_else(|e| e.into_inner());
let _removed_ids = data.fill_buffer(buffer);
// `_removed_ids` is the list of clients whose stream the
// handler just finished draining. We could publish that
+2 -1
View File
@@ -89,7 +89,8 @@ unsafe fn resolve_symbol(name: &'static [u8]) -> Option<*mut c_void> {
}
}
/// 16 kHz detector backed by Swift `SileroCoreML.SileroVAD`.
/// 16 kHz detector backed by Swift Silero CoreML VAD.
/// Processes 16 kHz frames and outputs speech probability.
pub struct AppleCoreMlVad {
handle: *mut c_void,
symbols: AppleSileroSymbols,
+38 -67
View File
@@ -8,20 +8,21 @@
#[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};
use std::sync::{OnceLock, RwLock};
use crate::frame::{f32_to_i16, i16_to_f32};
use crate::{AudioError, VadBackend};
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.
/// Contains speech probability and binary decision.
#[derive(Debug, Clone, Copy)]
pub struct VadOutput {
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
@@ -36,15 +37,19 @@ pub trait VoiceActivityDetector: Send {
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
}
/// Realtime-safe WebRTC VAD used when a model runtime is unavailable.
/// Realtime-safe WebRTC VAD fallback when ONNX runtime is unavailable.
/// Uses aggressive mode at 48 kHz for voice detection.
pub struct WebRtcFallbackVad {
vad: webrtc_vad::Vad,
frame_i16: [i16; INPUT_FRAME_10MS],
}
// `webrtc_vad::Vad` owns an FFI pointer and is only touched from the
// capture thread after construction. Moving the wrapper between threads is
// safe; sharing it concurrently is not required and not implemented.
// SAFETY: `webrtc_vad::Vad` wraps an opaque FFI pointer to the WebRTC C VAD
// state. The underlying C struct has no interior mutability that would cause
// data races when moved between threads — `WebRtcVad_Process()` reads/writes
// the struct exclusively through the passed pointer with no shared static state.
// This wrapper is only used from a single capture thread after construction;
// we never share `&WebRtcFallbackVad` across threads (no `Sync` impl).
unsafe impl Send for WebRtcFallbackVad {}
impl Default for WebRtcFallbackVad {
@@ -72,8 +77,8 @@ impl VoiceActivityDetector for WebRtcFallbackVad {
}
}
/// Wraps any `VoiceActivityDetector` that operates at 16 kHz and
/// downsamples 48 kHz input before forwarding.
/// Wraps any `VoiceActivityDetector` operating at 16 kHz,
/// downsampling 48 kHz input before forwarding to the detector.
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
inner: D,
downsampler: Downsampler48to16,
@@ -108,39 +113,12 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
detector.process_10ms(&frame)
}
/// Callback-side policy for optional model-backed VAD workers.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum VadWorkerPolicy {
/// Keep using the already-available model worker.
UseWorker,
/// No worker may be constructed on the callback thread; use WebRTC fallback.
UseFallback,
/// This backend does not need a model worker.
NotModelBacked,
}
/// Decide whether a realtime callback may use a model-backed VAD worker.
///
/// Model/worker construction is intentionally absent from this policy: if a
/// worker is not already present, callbacks must stay nonblocking and fall back.
pub(crate) fn callback_vad_worker_policy(
voice_activity_mode: bool,
backend: VadBackend,
worker_available: bool,
) -> VadWorkerPolicy {
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
return VadWorkerPolicy::NotModelBacked;
}
if worker_available {
VadWorkerPolicy::UseWorker
} else {
VadWorkerPolicy::UseFallback
}
}
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))
}
@@ -175,6 +153,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
@@ -264,13 +255,20 @@ mod tests {
#[test]
fn set_silero_model_path_rejects_missing_file() {
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
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_or_else(|e| e.into_inner());
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();
@@ -280,34 +278,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);
}
#[test]
fn callback_policy_uses_existing_model_worker_only() {
assert_eq!(
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
VadWorkerPolicy::UseWorker
);
assert_eq!(
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
VadWorkerPolicy::UseFallback
);
}
#[test]
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
assert_eq!(
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
VadWorkerPolicy::NotModelBacked
);
assert_eq!(
callback_vad_worker_policy(true, VadBackend::Disabled, false),
VadWorkerPolicy::NotModelBacked
);
assert_eq!(
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
VadWorkerPolicy::NotModelBacked
);
}
}
+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();
}
}
}
@@ -0,0 +1,139 @@
//! Poisoned-mutex survival test for audio callbacks (TODO-016).
//!
//! Verifies that the `unwrap_or_else(|e| e.into_inner())` recovery
//! pattern used throughout chanora_audio produces usable values
//! rather than panicking when a mutex is poisoned.
use std::panic;
use std::sync::{Arc, Mutex};
/// Simulates a simple config guard behind a mutex, matching the
/// pattern used by `audio_processing_config` in the engine.
#[derive(Debug, Clone, PartialEq)]
struct DummyConfig {
gain: f32,
muted: bool,
}
impl Default for DummyConfig {
fn default() -> Self {
Self {
gain: 1.0,
muted: false,
}
}
}
/// Poisons a `Mutex<T>` by panicking while holding its lock,
/// then catches the panic so the test can continue.
fn poison_mutex<T: Default>(mx: &Mutex<T>) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let _guard = mx.lock().unwrap();
panic!("deliberate poison");
}));
}
/// Helper that mirrors the exact recovery pattern used in production:
/// `lock().unwrap_or_else(|e| e.into_inner())`.
fn recover<T>(mx: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mx.lock().unwrap_or_else(|e| e.into_inner())
}
// ---------------------------------------------------------------------------
// Test 1: Basic poison recovery returns the inner value.
// ---------------------------------------------------------------------------
#[test]
fn poison_recovery_returns_inner_value() {
let mx = Mutex::new(DummyConfig::default());
{
let mut g = mx.lock().unwrap();
g.gain = 0.5;
g.muted = true;
}
poison_mutex(&mx);
assert!(mx.is_poisoned());
let guard = recover(&mx);
assert_eq!(guard.gain, 0.5);
assert!(guard.muted);
}
// ---------------------------------------------------------------------------
// Test 2: Recovered guard is mutable and usable (simulates an audio
// callback writing silence to the output buffer after recovery).
// ---------------------------------------------------------------------------
#[test]
fn recovered_guard_is_mutable() {
let mx: Mutex<Vec<f32>> = Mutex::new(vec![0.0; 256]);
{
let mut g = mx.lock().unwrap();
g.fill(0.75);
}
poison_mutex(&mx);
{
let mut guard = recover(&mx);
guard.fill(0.0);
}
let guard = recover(&mx);
assert!(guard.iter().all(|&s| s == 0.0), "expected silence after recovery");
}
// ---------------------------------------------------------------------------
// Test 3: Multi-lock scenario — recover from a poisoned mutex, mutate
// it, and verify subsequent reads see the updated state.
// ---------------------------------------------------------------------------
#[test]
fn recovered_state_persists_across_locks() {
let mx = Mutex::new(42u32);
poison_mutex(&mx);
*recover(&mx) = 99;
assert_eq!(*recover(&mx), 99);
assert!(mx.is_poisoned(), "mutex stays poisoned but remains usable");
}
// ---------------------------------------------------------------------------
// Test 4: Arc<Mutex<T>> pattern — mirrors the audio engine's shared
// state where multiple callbacks hold Arc clones.
// ---------------------------------------------------------------------------
#[test]
fn shared_arc_mutex_recovery() {
let mx = Arc::new(Mutex::new(DummyConfig::default()));
{
let mut g = mx.lock().unwrap();
g.gain = 0.8;
}
poison_mutex(&mx);
let mx2 = Arc::clone(&mx);
let guard = mx2.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(guard.gain, 0.8);
}
// ---------------------------------------------------------------------------
// Test 5: Snapshot-then-clone pattern — mirrors the engine's
// `audio_processing_config_snapshot()` which clones through the guard.
// ---------------------------------------------------------------------------
#[test]
fn snapshot_clone_through_poisoned_mutex() {
let mx = Mutex::new(DummyConfig {
gain: 0.42,
muted: true,
});
poison_mutex(&mx);
let snapshot = mx.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(snapshot.gain, 0.42);
assert!(snapshot.muted);
let snapshot2 = mx.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(snapshot, snapshot2);
}
+4 -4
View File
@@ -82,7 +82,7 @@ struct RecordingLayer {
impl RecordingLayer {
fn snapshot(&self) -> Vec<Captured> {
self.records.lock().unwrap().clone()
self.records.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
@@ -118,7 +118,7 @@ where
target: event.metadata().target().to_string(),
field_names: names.0,
};
self.records.lock().unwrap().push(captured);
self.records.lock().unwrap_or_else(|e| e.into_inner()).push(captured);
}
}
@@ -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);
+3
View File
@@ -36,3 +36,6 @@ ndk-context = "0.1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
[dev-dependencies]
serde_json = "1"
+80
View File
@@ -0,0 +1,80 @@
# chanora_bridge
Typed Flutter/Rust bridge — schema-controlled DTOs for commands, results, and events. Backed by `flutter_rust_bridge` 2.x per DEC-014.
## Architecture
- **`api` module** — all public functions exposed to Dart. Each function runs on a shared tokio runtime and delegates to `chanora_core::ChanoraSession`. Input/output types are owned primitives or `String`s — no backend types cross the boundary (SAD-067, SDD-079).
- **`frb_generated`** — auto-generated `flutter_rust_bridge` glue. Contains `unsafe` for the FFI boundary; hand-written code must not use `unsafe`.
- **`android_init`** (Android only) — NDK context initialization
- **`permission_jni`** (Android only) — JNI hook for Android permission state changes (SDD-106)
### DTO pattern
Every Dart-facing type is a `Bridge*` DTO with primitive fields. `From` impls convert between bridge DTOs and `chanora_core` types. Most types do not carry `serde` derives — FRB generates its own SSE encoders/decoders.
### Event streaming
`BridgeEvent` enum is streamed to Dart via FRB's `StreamSink`. Events include: Connected, Disconnected, Lost, Reconnecting, ChatMessage, VoiceState, AudioStarted/Stopped, ClientJoined/Left/Moved/Updated, ChannelAdded/Removed/Updated, PttCapability, PermissionState, ServerActivity, InterruptionState, AudioRouteChanged.
## Public API Summary
### Commands (api.rs)
| Command | Description |
|---|---|
| `bridge_init()` | One-time init: logging, panic hook |
| `connect(host, nickname, password)` | Connect to a server |
| `disconnect()` | Clean disconnect |
| `snapshot()` | Refresh server state |
| `client_profile(client_id)` | Rich profile for one client |
| `is_connected()` | Connection check |
| `prefetch_server(host)` | Warm server resolution |
| `voice_join(channel_id, password)` | Join voice channel |
| `voice_leave()` | Leave voice channel |
| `set_transmit_mode(mode)` | Ptt/Continuous/VoiceActivity |
| `get_transmit_mode()` | Read current mode |
| `set_hard_mute(muted)` | Hard-mute clamp |
| `set_ptt(active)` | Manual PTT press/release |
| `set_ptt_binding(input_class, platform_key)` | Bind a PTT key |
| `ptt_descriptor()` | Current PTT capability |
| `get_ptt_binding()` | Persisted PTT binding |
| `set_release_tail_ms(ms)` | Release-tail config |
| `get_release_tail_ms()` | Read release-tail |
| `move_to_channel(channel_id, password)` | Move to a channel |
| `set_input_muted(muted)` / `set_output_muted(muted)` | Server-side mute |
| `set_output_gain(gain)` | Master volume |
| `set_client_volume(client_id, volume)` | Per-client volume |
| `send_chat_message(message, target)` | Send text |
| `set_audio_processing_config(config)` | P1 audio processing |
| `get_audio_processing_config()` | Read P1 config |
| `audio_processing_stats()` | P1 telemetry |
| `enable_audio_debug_wav_dump(enabled)` | WAV dump toggle |
| `set_vad_model_path(path)` | Silero model path |
| `set_input_device(id)` / `set_output_device(id)` | Device selection |
| `export_diagnostics()` | Redacted export bundle (includes network stats) |
| `audio_stats()` | Audio subsystem statistics |
| `input_level_stream()` | Mic level metering stream |
| `events_stream()` | Bridge event stream |
| `log_file_path_str()` | Log file path for platform |
| `init_storage()` / `init_cache()` | Storage/cache initialization |
| `set_ios_voice_processing_mode(mode)` | iOS audio processing mode |
| `set_audio_output_route(route)` | Audio output route selection |
| `list_audio_devices()` | Enumerate audio devices |
| `list_bookmarks()` / `add_bookmark` / `update_bookmark` / `delete_bookmark` | Bookmark CRUD |
| `download_avatar(hash, uid)` / `download_icon(id)` | Avatar/icon download |
| `clear_file_cache()` / `file_cache_size()` | Cache management |
| `handle_route_change(route)` | iOS audio route change |
| `handle_media_services_reset_with_route(route_class)` | iOS media reset |
| `handle_interruption_began()` / `handle_interruption_ended(should_resume)` | iOS interruption |
| `lifecycle_event(state)` | Platform lifecycle |
### Bridge DTOs
`BridgeSnapshot`, `BridgeChannel`, `BridgeClient`, `BridgeClientProfile`, `BridgeAudioStats`, `BridgeAudioProcessingConfig`, `BridgeAudioProcessingStats`, `BridgeAudioRoute`, `BridgeTransmitMode`, `BridgePttInputClass`, `BridgePttDescriptor`, `BridgePttBinding`, `BridgeMessageTarget`, `BridgeBookmark`, `PermissionStateKind`, `BridgeError`.
## Platform notes
- Cannot use `#![forbid(unsafe_code)]` because FRB-generated glue legitimately uses `unsafe` for the FFI boundary.
- Android: includes `android_init` and `permission_jni` modules gated behind `cfg(target_os = "android")`.
- iOS: route-change and interruption handlers are synchronous (`#[frb(sync)]`), dispatched to the tokio runtime via an ordered channel.
+99 -24
View File
@@ -603,6 +603,8 @@ pub async fn connect(
nickname: String,
password: String,
) -> Result<BridgeSnapshot, BridgeError> {
let nickname = chanora_core::validate_nickname(&nickname)
.map_err(|e| BridgeError::InvalidCommand(e.to_string()))?;
let cfg = chanora_core::ConnectConfig {
address: host,
nickname,
@@ -837,6 +839,15 @@ pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
Ok(())
}
/// Read the current hard-mute state. Returns `true` when the audio
/// engine is clamped and transmits nothing regardless of mode.
pub async fn is_hard_muted() -> bool {
runtime()
.spawn(async { session().hard_mute() })
.await
.unwrap_or(false)
}
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
/// the bridge never carries raw key codes.
#[derive(Debug, Clone, Copy)]
@@ -999,6 +1010,15 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
Ok(())
}
/// Read the current master output gain. Returns `1.0` (unity) when
/// audio is not started.
pub async fn get_output_gain() -> f32 {
runtime()
.spawn(async { session().output_gain().await })
.await
.unwrap_or(1.0)
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
@@ -1062,10 +1082,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 +1241,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 +1251,6 @@ impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
Self::PlatformVoiceProcessing
}
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
}
}
}
@@ -1469,6 +1485,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 +1759,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 +1878,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 +2045,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 }
@@ -2325,7 +2414,7 @@ pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeErro
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
.await
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
.map_err(|e| BridgeError::unmapped_ctx("enable_audio_debug_wav_dump", e))?;
Ok(())
}
@@ -2336,25 +2425,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) {
+320 -1
View File
@@ -51,11 +51,15 @@ use thiserror::Error;
/// Errors raised at the bridge boundary. Production code must keep
/// these user-safe — no secrets, no protocol details, no path
/// information beyond what the redaction policy permits.
#[derive(Debug, Error, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Error, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum BridgeError {
/// The caller submitted a malformed command DTO.
#[error("invalid command: {0}")]
InvalidCommand(String),
// TODO(refactor): DnsFailed and ServerRejected mirror ProtocolError variants
// in chanora_protocol. These cannot be unified without changing the public FFI
// API (flutter_rust_bridge generates Dart types from these). Revisit only if
// the bridge error types are being reworked.
/// Hostname resolution failed. Distinct from `Connection` so the
/// UI can show a meaningful "Server not found" message.
#[error("dns: could not resolve '{host}': {reason}")]
@@ -94,6 +98,12 @@ pub enum BridgeError {
Unmapped(String),
}
impl BridgeError {
fn unmapped_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
BridgeError::Unmapped(format!("{ctx}: {e}"))
}
}
impl From<chanora_core::CoreError> for BridgeError {
fn from(e: chanora_core::CoreError) -> Self {
match e {
@@ -110,10 +120,319 @@ 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}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip_json<
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
>(
value: &T,
) {
let json = serde_json::to_string(value).expect("serialize");
let back: T = serde_json::from_str(&json).expect("deserialize");
assert_eq!(&back, value, "roundtrip failed");
}
#[test]
fn bridge_error_invalid_command() {
let err = BridgeError::InvalidCommand("bad".to_string());
assert_eq!(err.to_string(), "invalid command: bad");
}
#[test]
fn bridge_error_dns_failed() {
let err = BridgeError::DnsFailed {
host: "example.com".to_string(),
reason: "timeout".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("example.com"));
assert!(msg.contains("timeout"));
}
#[test]
fn bridge_error_connection() {
let err = BridgeError::Connection("refused".to_string());
assert_eq!(err.to_string(), "connection: refused");
}
#[test]
fn bridge_error_not_connected() {
let err = BridgeError::NotConnected;
assert_eq!(err.to_string(), "not connected");
}
#[test]
fn bridge_error_already_connected() {
let err = BridgeError::AlreadyConnected;
assert_eq!(err.to_string(), "already connected");
}
#[test]
fn bridge_error_server_rejected() {
let err = BridgeError::ServerRejected {
code: 2568,
message: "insufficient permissions".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("2568"));
assert!(msg.contains("insufficient permissions"));
}
#[test]
fn bridge_error_unmapped() {
let err = BridgeError::Unmapped("mystery".to_string());
assert_eq!(err.to_string(), "unmapped: mystery");
}
#[test]
fn bridge_error_serde_roundtrip() {
roundtrip_json(&BridgeError::InvalidCommand("test".to_string()));
roundtrip_json(&BridgeError::NotConnected);
roundtrip_json(&BridgeError::AlreadyConnected);
roundtrip_json(&BridgeError::Connection("fail".to_string()));
roundtrip_json(&BridgeError::Unmapped("x".to_string()));
roundtrip_json(&BridgeError::DnsFailed {
host: "h".to_string(),
reason: "r".to_string(),
});
roundtrip_json(&BridgeError::ServerRejected {
code: 42,
message: "nope".to_string(),
});
}
#[test]
fn bridge_error_clone_preserves() {
let err = BridgeError::InvalidCommand("orig".to_string());
let cloned = err.clone();
assert_eq!(cloned.to_string(), err.to_string());
}
#[test]
fn from_core_error_not_connected() {
let core_err = chanora_core::CoreError::NotConnected;
let bridge_err: BridgeError = core_err.into();
assert!(matches!(bridge_err, BridgeError::NotConnected));
}
#[test]
fn from_core_error_already_connected() {
let core_err = chanora_core::CoreError::AlreadyConnected;
let bridge_err: BridgeError = core_err.into();
assert!(matches!(bridge_err, BridgeError::AlreadyConnected));
}
#[test]
fn from_core_error_audio_not_started() {
let core_err = chanora_core::CoreError::AudioNotStarted;
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::InvalidCommand(msg) => {
assert!(msg.contains("audio not started"));
}
other => panic!("expected InvalidCommand, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_dns_failed() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::DnsFailed {
host: "bad.host".to_string(),
reason: "no address".to_string(),
},
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::DnsFailed { host, reason } => {
assert_eq!(host, "bad.host");
assert_eq!(reason, "no address");
}
other => panic!("expected DnsFailed, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_server_rejected() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::ServerRejected {
code: 0x0501,
message: "channel password wrong".to_string(),
},
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::ServerRejected { code, message } => {
assert_eq!(code, 0x0501);
assert_eq!(message, "channel password wrong");
}
other => panic!("expected ServerRejected, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_file_transfer() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::FileTransfer("disk full".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("file transfer"));
assert!(msg.contains("disk full"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_generic() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::Connect("refused".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("refused"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_lost() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::Lost("timeout".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("timeout"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_invalid() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::Invalid("bad config".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("bad config"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_disconnected_early() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::DisconnectedEarly("premature".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("premature"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_identity() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::Identity("parse error".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("parse error"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_timeout() {
let core_err =
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::Timeout);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("timeout"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_protocol_backend() {
let core_err = chanora_core::CoreError::Protocol(
chanora_core::ProtocolError::Backend("raw".to_string()),
);
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Connection(msg) => {
assert!(msg.contains("raw"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn from_core_error_invariant() {
let core_err = chanora_core::CoreError::Invariant("broken");
let bridge_err: BridgeError = core_err.into();
match bridge_err {
BridgeError::Unmapped(msg) => {
assert!(msg.contains("broken"));
}
other => panic!("expected Unmapped, got {other:?}"),
}
}
#[test]
fn error_surfaces_all_protocol_error_variants() {
let protocol_errors: Vec<chanora_core::ProtocolError> = vec![
chanora_core::ProtocolError::Invalid("x".into()),
chanora_core::ProtocolError::DnsFailed {
host: "h".into(),
reason: "r".into(),
},
chanora_core::ProtocolError::Connect("c".into()),
chanora_core::ProtocolError::DisconnectedEarly("d".into()),
chanora_core::ProtocolError::Lost("l".into()),
chanora_core::ProtocolError::Identity("i".into()),
chanora_core::ProtocolError::Timeout,
chanora_core::ProtocolError::ServerRejected {
code: 1,
message: "m".into(),
},
chanora_core::ProtocolError::Backend("b".into()),
chanora_core::ProtocolError::FileTransfer("f".into()),
];
for p_err in protocol_errors {
let core_err = chanora_core::CoreError::Protocol(p_err);
let bridge_err: BridgeError = core_err.into();
let msg = bridge_err.to_string();
assert!(!msg.is_empty(), "BridgeError message must not be empty");
}
}
}
+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"] }
+39
View File
@@ -0,0 +1,39 @@
# chanora_cache
Disposable content-addressed blob cache for avatar and icon files. Wraps `cacache` for crash safety and integrity verification. Separated from `chanora_storage` because cache owns reconstructible, disposable blob data with different durability and backup semantics.
## Architecture
- **`BlobCache`** — async blob store backed by cacache's content-v2 / index-v2 on-disk layout.
- Keys are protocol identifiers prefixed by type: `av_<32-char-hex>` for avatars (MD5), `ic_<decimal>` for icons (CRC32).
- Cacache handles dedup and SSRI integrity verification on every read.
- Corrupt entries are automatically removed on read failure.
- LRU eviction by timestamp when total size exceeds the configured cap.
## Public API Summary
### Types
| Type | Role |
|---|---|
| `BlobCache` | Content-addressed blob cache |
| `BlobCacheError` | Io, InvalidKey |
### Key methods on `BlobCache`
- `new(cache_dir, max_bytes)` — create or open the cache. `max_bytes = 0` disables eviction.
- `put(prefix, key, data)` — store a blob (async)
- `get(prefix, key)``Option<Vec<u8>>` — read a blob, with integrity check (async)
- `remove(prefix, key)` — delete a specific blob (async)
- `clear()` — delete all blobs (async)
- `total_size()``u64` — sum of all blob sizes (async)
- `evict()` — remove oldest entries until under `max_bytes` cap (async)
### Constants
- `PREFIX_AVATAR` = `"av_"` — avatar key prefix
- `PREFIX_ICON` = `"ic_"` — icon key prefix
## Key validation
Avatar keys must be exactly 32 hex characters. Icon keys must be non-empty decimal digits. Unknown prefixes are rejected. This prevents malformed entries from polluting the cache.
+368
View File
@@ -0,0 +1,368 @@
//! 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.
// TODO(refactor): Io(String) variant is duplicated across chanora_cache,
// chanora_storage, and chanora_diagnostics. Could use a shared error type
// or derive From<std::io::Error> instead of manually wrapping.
#[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),
}
impl BlobCacheError {
fn io_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
BlobCacheError::Io(format!("{ctx}: {e}"))
}
}
/// 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_ctx("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_ctx("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_ctx("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_ctx("clear cache", e))?;
std::fs::create_dir_all(&path)
.map_err(|e| BlobCacheError::io_ctx("recreate cache dir", e))?;
}
Ok(())
})
.await
.map_err(|e| BlobCacheError::io_ctx("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_ctx("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_ctx("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());
}
}
+80
View File
@@ -0,0 +1,80 @@
# chanora_diagnostics
Application diagnostics: log redaction, in-memory log capture, and user-initiated diagnostic export. Per DEC-016, export is **user-initiated only**; there is no automatic upload.
## Architecture
### Redaction policy
`Redactor` applies the production policy (REDACT-TC-001..010):
1. Known-secret registry — substring match → `[REDACTED]`
2. `$HOME` prefix → `[home]`
3. IPv4 addresses → `[ip]`
4. IPv6 addresses → `[ip]`
5. Email-shaped strings → `[email]`
6. Long opaque tokens (base64 ≥32 chars, ≥75% alnum) → `[token]`
### PTT sanitiser
`PttSanitizer<L>` — a `tracing-subscriber` Layer decorator that drops any record containing field names from the banned list (`key_code`, `scan_code`, `virtual_key`, `keysym`, etc.) per DEC-027 / REDACT-PTT-001..006. Allocation-free on the success path.
### Log capture
`InMemoryLogSink` — bounded ring buffer that passes every line through the redactor before storing. Capacity differs by build: 4096 lines (debug), 256 lines (release) per SRS-122.
### Event recorder
`ProtocolEventRecorder` — ring buffer of protocol-level events (connect, disconnect, reconnect, snapshot changes, channel joins) for diagnostic export and state-sync replay verification (SRS-097/098).
### Export
`DiagnosticExport` — serialisable bundle containing:
- Client metadata (version, platform)
- Redacted recent logs
- Known-secret count (values never exported)
- Optional Android audio diagnostics YAML
- Optional network diagnostics summary
- Protocol event trace
## Public API Summary
### Types
| Type | Role |
|---|---|
| `Redactor` | Production redaction policy (cheap to clone) |
| `KnownSecretRegistry` | Cross-spike secret registry for defence in depth (SS-AUD-003) |
| `InMemoryLogSink` | Bounded ring buffer of redacted log lines |
| `RedactingLogLayer` | `tracing-subscriber` Layer feeding `InMemoryLogSink` |
| `PttSanitizer<L>` | Layer decorator dropping PTT-sensitive records |
| `DiagnosticExport` | User-facing export bundle |
| `ProtocolEventRecorder` | Protocol event ring buffer (SRS-097) |
| `DiagnosticsError` | Export, Io |
| `REDACTION_MARKER` | `"[REDACTED]"` |
### Key methods
**Redactor:**
- `with_default_policy()` / `with_secrets(registry)` — construct
- `redact(s)``String` — apply policy
- `secrets()``&KnownSecretRegistry` — register secrets
**KnownSecretRegistry:**
- `register(secret)` — add a known-secret value (≥4 chars)
- `contains_substr(haystack)``bool` — substring check
**InMemoryLogSink:**
- `new(capacity, redactor)` — construct
- `push(raw)` — redact and store a line
- `snapshot()``Vec<String>` — current buffer contents
**DiagnosticExport:**
- `from_sink(sink, metadata)` — build from log sink
- `with_android_audio(yaml)` / `with_network_info(info)` / `with_protocol_events(events)` — attach optional sections
- `to_text()``String` — render as multi-line plaintext
**ProtocolEventRecorder:**
- `new(capacity)` — construct
- `record_connected(server_name)` / `record_disconnected(reason)` / `record_reconnecting(attempt, delay)`
- `drain()``Vec<String>` / `snapshot()``Vec<String>`
+48
View File
@@ -0,0 +1,48 @@
# chanora_prefetch
Server-address prefetch cache and policy. Owns speculative server-resolution warming so that when the user presses Connect, a fresh DNS/SRV result may already be available, reducing perceived join latency.
## Architecture
### Cache model
`ServerPrefetchCache` holds at most one entry — the latest prefetched resolution. A generation counter prevents stale async completions from overwriting newer results. TTL is 120 seconds.
### Flow
1. Flutter typing triggers `prefetch_server(host)` via the bridge.
2. `ServerPrefetcher::prefetch()` normalizes the host, bumps the generation, and spawns a fire-and-forget tokio task that calls `chanora_resolver::ChanoraResolver::resolve_client_address()`.
3. On success, the result is stored if its generation is still current.
4. When `chanora_core::connect()` is called, it checks `fresh_match(host)`. If a fresh (non-expired) entry matches, it's used as the `resolved_address` in `ConnectConfig`, bypassing a second DNS round trip.
### Generation guard
If the user types another host while the first prefetch is in flight, the generation advances. The slower completion is discarded because its generation no longer matches. The most recent entry always wins.
## Public API Summary
### Types
| Type | Role |
|---|---|
| `ServerPrefetcher` | Public API: schedule prefetches, query fresh matches |
| `ServerPrefetchError` | ResolverInit, Resolution, InvalidSocketAddress |
### Key methods on `ServerPrefetcher`
- `new()` — construct with empty cache
- `prefetch(host)` — schedule a fire-and-forget resolution (async). Only reports synchronous setup failures; DNS failures are logged.
- `fresh_match(host)``Option<SocketAddr>` — return a cached address if it matches and is within TTL (async)
### Test-only methods (behind `cfg(test)` or `feature = "test-support"`)
- `begin_for_test(host)` — bump generation
- `store_success_for_test(generation, host, addr, instant)` — inject a result
- `latest_generation_for_test()` — read current generation
- `fail_next_prefetch_setup_for_test(error)` — inject a setup failure
## Design notes
- Blank/whitespace-only hosts are silently skipped.
- Hosts are normalized to lowercase trimmed strings before matching.
- A fresh entry remains usable while a newer prefetch is in flight; stale completions are rejected by the generation guard.
+3
View File
@@ -194,6 +194,9 @@ impl ServerPrefetcher {
}
}
// TODO(refactor): `normalize_host` duplicates `chanora_resolver::normalize_args`
// host trimming. Both do `host.trim().to_lowercase()`. Could move to a shared
// utility in chanora_protocol or a tiny chanora_common crate if more crates need it.
fn normalize_host(host: &str) -> String {
host.trim().to_lowercase()
}

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