Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ffde2752 |
+31
-46
@@ -1,49 +1,34 @@
|
||||
# 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.
|
||||
|
||||
# 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.
|
||||
[env]
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
|
||||
# Scope the cmake wrapper to Windows MSVC targets only via the
|
||||
# target-suffixed env var name that cc/cmake-rs already resolve.
|
||||
# Force = true so a developer's pre-existing CMAKE_x86_64-pc-windows-msvc
|
||||
# does not silently bypass the wrapper. Relative = true so the path
|
||||
# resolves from the workspace root regardless of where cargo is invoked.
|
||||
CMAKE_x86_64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||
CMAKE_aarch64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||
# 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"]
|
||||
|
||||
@@ -125,6 +125,7 @@ opencode.json
|
||||
/apps/chanora_flutter/macos/Frameworks/
|
||||
.opencode/
|
||||
.omo/
|
||||
AGENTS.md
|
||||
Screenshot 2026-05-17 at 22.23.07.png
|
||||
|
||||
# Xcode archive / export bundles (generated by Product > Archive > Distribute)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
[submodule "silero-coreml"]
|
||||
path = silero-coreml
|
||||
url = git@github.com:chanoraapp/silero-coreml.git
|
||||
[submodule "docs"]
|
||||
path = docs
|
||||
url = git@github.com:chanoraapp/docs.git
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# AGENTS.md — Chanora Project Conventions
|
||||
|
||||
## Project Overview
|
||||
|
||||
Chanora is a cross-platform voice client (Flutter + Rust) targeting TeamSpeak-compatible servers. The project follows ASPICE engineering processes with full traceability from system requirements through verification.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
chanora/ ← Code repo (this one)
|
||||
├── docs/ → chanoraapp/docs ← Git submodule: ASPICE docs, Docusaurus doc site
|
||||
├── dev-docs/ ← Local-only development docs
|
||||
│ ├── superpowers/ ← AI agent specs and plans
|
||||
│ │ ├── specs/ ← Feature/design specs (active)
|
||||
│ │ └── plans/ ← Implementation plans (active)
|
||||
│ │ └── _archived/ ← Completed plans
|
||||
│ ├── offline-knowledge/ ← Doc maintenance tools, link coverage
|
||||
│ ├── implementation-status-* ← Code state snapshots
|
||||
│ └── release/ios-build.md ← Operational build instructions
|
||||
├── apps/chanora_flutter/ ← Flutter application
|
||||
├── core/chanora_core/ ← Rust core API + orchestration
|
||||
├── crates/ ← Rust crates (protocol, audio, state, etc.)
|
||||
└── dev-docs/impl-mapping.md ← SAD component → source file mapping
|
||||
```
|
||||
|
||||
## Documentation Two-Repo Model
|
||||
|
||||
**`docs/` is a git submodule** pointing to the `chanoraapp/docs` repository. It serves a Docusaurus doc site with ASPICE traceability. It is NOT a local directory you can freely create files in.
|
||||
|
||||
### What lives where
|
||||
|
||||
| Content | Location | Reason |
|
||||
|---|---|---|
|
||||
| SysRS, SysDes, SRS, SAD, SDD | `docs/` (submodule) | ASPICE baselines, served on doc site |
|
||||
| Verification plans (SWE.4/5/6, SYS.4) | `docs/` (submodule) | ASPICE verification evidence |
|
||||
| Governance, traceability, decision register | `docs/` (submodule) | ASPICE governance |
|
||||
| Security, privacy, legal | `docs/` (submodule) | Stakeholder-facing |
|
||||
| UI/UX guidelines, i18n architecture | `docs/` (submodule) | Design references |
|
||||
| Feature specs and implementation plans | `dev-docs/superpowers/` | Code-coupled, agent working files |
|
||||
| Link coverage, doc quality analysis | `dev-docs/offline-knowledge/` | Maintenance tools |
|
||||
| Implementation status snapshots | `dev-docs/` | Code state tracking |
|
||||
| Source file path references | `dev-docs/impl-mapping.md` | Developer convenience, not ASPICE |
|
||||
|
||||
### Rules for agents
|
||||
|
||||
1. **Never create or edit files in `docs/`** without understanding it's a submodule. Changes there require committing in the `chanoraapp/docs` repo first, then updating the submodule pointer in this repo.
|
||||
|
||||
2. **ASPICE documents do not contain code file paths.** ASPICE traces requirement IDs (e.g., `SysRS-233`, `SRS-045`, `SDD-MOD-009`), not source file paths. If you need to map a component to its source, use or update `dev-docs/impl-mapping.md`.
|
||||
|
||||
3. **Specs and plans go in `dev-docs/superpowers/`.** Follow the naming convention: `YYYY-MM-DD-<topic>-design.md` for specs, `YYYY-MM-DD-<topic>.md` for plans.
|
||||
|
||||
4. **Completed plans move to `_archived/`.** Once a plan is fully implemented and verified, move it to `dev-docs/superpowers/plans/_archived/`.
|
||||
|
||||
5. **Doc site uses Docusaurus.** The `chanoraapp/docs` repo uses Docusaurus 3.10 (Meta-maintained). Do not add MkDocs, mdBook, or other doc site generators.
|
||||
|
||||
## ASPICE Traceability Chain
|
||||
|
||||
```text
|
||||
SysRS → SysDes → SRS → SAD (SWE.2) → SDD (SWE.3) → Verification
|
||||
↓ ↓
|
||||
SWE.4 (unit) SWE.4/5/6/SYS.4
|
||||
```
|
||||
|
||||
- Requirement IDs are the traceability mechanism, not file paths.
|
||||
- Every downstream document must reference upstream IDs it traces from.
|
||||
- Verification plans map to their upstream design/requirements level:
|
||||
- SYS.4 ← SysDes, SysRS
|
||||
- SWE.5 ← SAD (SWE.2)
|
||||
- SWE.4 ← SDD (SWE.3)
|
||||
- SWE.6 ← SRS
|
||||
- Requirement IDs should be added to doc front matter `tags:` for traceability browsing.
|
||||
|
||||
## Code Architecture
|
||||
|
||||
| Component | Location | Responsibility |
|
||||
|---|---|---|
|
||||
| Flutter app shell | `apps/chanora_flutter/` | UI, Material 3, navigation, localization |
|
||||
| Rust core | `core/chanora_core/` | Session orchestration, bridge events |
|
||||
| Protocol adapter | `crates/chanora_protocol/` | TeamSpeak protocol via tsclientlib |
|
||||
| State sync | `crates/chanora_state/` | Snapshots, deltas, reducers |
|
||||
| Audio subsystem | `crates/chanora_audio/` | Capture, DSP, Opus, PTT |
|
||||
| Storage | `crates/chanora_storage/` | Bookmarks, identity, encryption |
|
||||
| Diagnostics | `crates/chanora_diagnostics/` | Redaction, logs, export |
|
||||
| Resolver | `crates/chanora_resolver/` | SRV/TSDNS/DNS resolution |
|
||||
| Prefetch | `crates/chanora_prefetch/` | Resolution warming, TTL cache |
|
||||
| Bridge | `crates/chanora_bridge/` | Flutter/Rust typed DTO boundary |
|
||||
| Cache | `crates/chanora_cache/` | Avatar/icon blob cache |
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- **Rust:** Follow workspace `Cargo.toml` structure. Run `cargo check`, `cargo clippy`, `cargo test` before committing.
|
||||
- **Flutter:** Run `flutter analyze`, `flutter test` before committing.
|
||||
- **No code comments** unless explicitly requested.
|
||||
- **Git commits:** Follow convention in `docs/governance/git-commit-message-convention.md` (accessible via submodule).
|
||||
- **Bridge boundary:** Flutter must not directly depend on protocol-library internals. All cross-boundary communication goes through `chanora_bridge` typed DTOs.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
cargo check && cargo clippy && cargo test
|
||||
cd apps/chanora_flutter && flutter analyze && flutter test
|
||||
```
|
||||
|
||||
## Important References
|
||||
|
||||
- Traceability matrix: `docs/governance/traceability-matrix.md`
|
||||
- Decision register: `docs/governance/product-decision-register.md`
|
||||
- Security guidelines: `docs/security/security-privacy-legal-guideline.md`
|
||||
- Release readiness: `docs/release/release-readiness-go-nogo-record.md`
|
||||
- Doc site repo: `chanoraapp/docs` (Docusaurus)
|
||||
- Doc site local preview: `cd docs && npm run start`
|
||||
+12
-16
@@ -8,10 +8,8 @@ This project follows a Conventional Commits style workflow.
|
||||
|
||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||
prototype to a cross-platform baseline client with event-driven UI,
|
||||
visible per-client audio state, non-self client info parity, and
|
||||
documented host Rust workspace plus Flutter validation gates. Android
|
||||
target compile/install/smoke evidence remains blocked locally pending the
|
||||
required NDK compiler and an authorized ADB target.
|
||||
per-user audio controls, non-self client info parity, and CI-hardened
|
||||
Android / iOS / macOS / Linux builds.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -19,9 +17,10 @@ required NDK compiler and an authorized ADB target.
|
||||
deltas (client join/leave/move/update, channel add/remove/update)
|
||||
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||
in real time. Channel switching is instant.
|
||||
- **Per-client audio state visibility.** Client rows surface
|
||||
muted/deafened state in avatar badges. Per-user volume UI, persistence,
|
||||
and mixer wiring remain tracked as follow-up work.
|
||||
- **Per-user volume controls.** Each client in the snapshot gets an
|
||||
independent volume slider persisted in the bridge layer. Avatar
|
||||
badges show muted/deafened state. Volume adjustments take effect
|
||||
immediately on the audio mix.
|
||||
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||
connection metadata (name, description, created, last connected,
|
||||
connections, transfer, ping deviation) for other clients via an
|
||||
@@ -30,10 +29,9 @@ required NDK compiler and an authorized ADB target.
|
||||
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||
propagated from protocol DTO through bridge API to Dart, with a
|
||||
conditional l10n row in the client info sheet (en + zh).
|
||||
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
|
||||
the private `silero-coreml` SwiftPM package is available. Product
|
||||
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
|
||||
baseline enables and verifies it.
|
||||
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
||||
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
||||
available. WebRTC VAD remains the runtime fallback.
|
||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||
lookups and `ts3server://` URI handling.
|
||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||
@@ -52,9 +50,8 @@ required NDK compiler and an authorized ADB target.
|
||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||
audio startup.
|
||||
- **Linux native audio path promoted** with ONNX Runtime VAD assets
|
||||
bundled for future `VoiceActivity` work. Desktop voice I/O works on
|
||||
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
|
||||
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
||||
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||
startup permission flow, and system back-button integration.
|
||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||
@@ -62,8 +59,7 @@ required NDK compiler and an authorized ADB target.
|
||||
Flutter).
|
||||
- **Prefetch crate renamed** from the PoC-era name to
|
||||
`chanora_prefetch`. All docs, specs, and code updated.
|
||||
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
||||
packages remain versioned separately at `0.2.0-beta.1`.
|
||||
- **Build number bumped to 76.**
|
||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+16
-368
@@ -148,111 +148,12 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"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"
|
||||
@@ -264,57 +165,6 @@ 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"
|
||||
@@ -407,12 +257,6 @@ 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"
|
||||
@@ -464,19 +308,6 @@ 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"
|
||||
@@ -521,32 +352,6 @@ 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"
|
||||
@@ -670,23 +475,11 @@ 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",
|
||||
@@ -722,7 +515,7 @@ name = "chanora_protocol"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"chanora_resolver",
|
||||
"futures",
|
||||
"reqwest 0.13.4",
|
||||
@@ -739,7 +532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chanora_resolver"
|
||||
version = "0.2.0-beta.1"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hickory-resolver",
|
||||
@@ -761,7 +554,7 @@ dependencies = [
|
||||
name = "chanora_storage"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"keyring",
|
||||
"rand 0.8.6",
|
||||
@@ -1446,12 +1239,6 @@ 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"
|
||||
@@ -1469,7 +1256,7 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -1772,18 +1559,6 @@ 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"
|
||||
@@ -2062,7 +1837,7 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -2367,15 +2142,6 @@ 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"
|
||||
@@ -2460,9 +2226,6 @@ 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"
|
||||
@@ -2511,15 +2274,6 @@ 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"
|
||||
@@ -2529,29 +2283,6 @@ 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"
|
||||
@@ -3082,17 +2813,6 @@ 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"
|
||||
@@ -3137,20 +2857,6 @@ 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"
|
||||
@@ -3477,18 +3183,6 @@ 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"
|
||||
@@ -3524,7 +3218,7 @@ version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
@@ -3562,7 +3256,7 @@ version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
@@ -3978,17 +3672,6 @@ 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"
|
||||
@@ -4168,23 +3851,6 @@ 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"
|
||||
@@ -4683,7 +4349,7 @@ name = "ts-bookkeeping"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"heck",
|
||||
"itertools 0.14.0",
|
||||
"num-derive",
|
||||
@@ -4704,7 +4370,7 @@ version = "0.2.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"audiopus",
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"futures",
|
||||
"git-testament",
|
||||
"hickory-net",
|
||||
@@ -4732,7 +4398,7 @@ version = "0.2.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"curve25519-dalek-ng",
|
||||
"eax",
|
||||
"futures",
|
||||
@@ -4761,7 +4427,7 @@ name = "tsproto-packets"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"bitflags 2.12.1",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
@@ -4776,7 +4442,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 0.22.1",
|
||||
"base64",
|
||||
"csv",
|
||||
"heck",
|
||||
"once_cell",
|
||||
@@ -4789,7 +4455,7 @@ name = "tsproto-structs"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"csv",
|
||||
"heck",
|
||||
"once_cell",
|
||||
@@ -4802,7 +4468,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 0.22.1",
|
||||
"base64",
|
||||
"bitflags 2.12.1",
|
||||
"curve25519-dalek-ng",
|
||||
"elliptic-curve",
|
||||
@@ -4846,12 +4512,6 @@ 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"
|
||||
@@ -4910,12 +4570,6 @@ 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"
|
||||
@@ -5602,12 +5256,6 @@ 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"
|
||||
@@ -5641,7 +5289,7 @@ dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
# 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
|
||||
@@ -31,7 +30,6 @@ members = [
|
||||
"crates/chanora_state",
|
||||
"crates/chanora_audio",
|
||||
"crates/chanora_storage",
|
||||
"crates/chanora_cache",
|
||||
"crates/chanora_diagnostics",
|
||||
"crates/chanora_prefetch",
|
||||
"crates/chanora_bridge",
|
||||
|
||||
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
|
||||
|
||||
## Status
|
||||
|
||||
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
||||
Chanora is currently in early planning and baseline-candidate design.
|
||||
|
||||
```text
|
||||
Current documentation baseline: v0.9.x document set
|
||||
Current documentation baseline: v0.9.2
|
||||
Current status: Baseline Candidate
|
||||
Implementation status: Not production-ready
|
||||
```
|
||||
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
|
||||
The current engineering focus is:
|
||||
|
||||
- defining the system and software architecture;
|
||||
- hardening the Flutter + Rust application structure;
|
||||
- preparing the Flutter + Rust application structure;
|
||||
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
||||
- defining cross-platform audio behavior;
|
||||
- preparing release, verification, security, privacy, and legal gates.
|
||||
@@ -49,7 +49,7 @@ Current platform policy:
|
||||
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
||||
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
||||
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
||||
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
||||
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||
|
||||
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
||||
@@ -230,7 +230,7 @@ docs/
|
||||
aspice-swe2-swe3-integration-note.md
|
||||
```
|
||||
|
||||
Implementation source folders are present in this workspace. The current high-level structure is:
|
||||
Implementation source folders may be added later. A likely structure is:
|
||||
|
||||
```text
|
||||
apps/
|
||||
@@ -248,7 +248,7 @@ crates/
|
||||
chanora_bridge/
|
||||
```
|
||||
|
||||
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
||||
The exact implementation layout should be finalized when the repository scaffold is created.
|
||||
|
||||
---
|
||||
|
||||
@@ -395,7 +395,9 @@ docs/governance/git-commit-message-convention.md
|
||||
|
||||
## Development
|
||||
|
||||
Common local commands include:
|
||||
Implementation commands will be added after the repository scaffold is finalized.
|
||||
|
||||
Expected future commands may include:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
@@ -405,7 +407,7 @@ cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
||||
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ android {
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -199,7 +198,6 @@ 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 {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/ic_chanora_notification" />
|
||||
@@ -1,9 +1,6 @@
|
||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Mirror Release.xcconfig (see explanation there). `-u` is the load-bearing
|
||||
// flag: without it the linker drops Swift @_cdecl symbols (no Swift caller)
|
||||
// before `-exported_symbol` can re-export them, and the verify_silero_exports
|
||||
// build phase fails the build.
|
||||
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
||||
// 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
|
||||
STRIP_STYLE = non-global
|
||||
|
||||
@@ -23,7 +23,7 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0
|
||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||
|
||||
@@ -6,24 +6,6 @@ import AVFoundation
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||
private var iosPlatformChannel: FlutterMethodChannel?
|
||||
private var iosAudioSessionChannel: FlutterMethodChannel?
|
||||
|
||||
/// Tracks whether a voice channel is currently active.
|
||||
///
|
||||
/// The AVAudioSession is intentionally not configured for VoIP at
|
||||
/// app launch — that would interrupt other apps' audio (Spotify,
|
||||
/// Apple Music, podcasts) the moment the user opens Chanora, even
|
||||
/// when they're just reading chat. Production VoIP apps (Telegram
|
||||
/// group calls, Signal, Discord, Element) only switch the session
|
||||
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
|
||||
/// a voice channel. See `docs/architecture/sad.md` and the
|
||||
/// `chanora/ios_audio_session` MethodChannel contract.
|
||||
///
|
||||
/// This flag gates lifecycle handlers (interruption-ended,
|
||||
/// media-services-reset) so we only rebuild the VoIP session if a
|
||||
/// call is actually in progress. When false, those handlers leave
|
||||
/// the session in the inactive `.ambient` baseline.
|
||||
private var voiceSessionActive: Bool = false
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -33,28 +15,91 @@ import AVFoundation
|
||||
ChanoraSileroSelfTest.run()
|
||||
}
|
||||
|
||||
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
||||
// the launch-time .playAndRecord setup):
|
||||
// Configure the iOS AVAudioSession **category + mode** at
|
||||
// app-launch time, but DEFER setActive(true) until the scene
|
||||
// is foregrounded. Calling setActive in didFinishLaunching is
|
||||
// racy on iOS 17+ devices: if the user launches the app from a
|
||||
// cold state, the UIApplication isn't yet `.active` and
|
||||
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
||||
// (561017449) — the iOS audio policy server refuses to grant
|
||||
// the audio session because the app is not yet considered the
|
||||
// foreground priority owner. Symptom in production builds:
|
||||
// 'AVAudioSession setup failed: Error 561017449 "Session
|
||||
// activation failed"' in NSLog, after which the audio engine
|
||||
// is unusable until the user backgrounds + foregrounds the
|
||||
// app.
|
||||
//
|
||||
// At launch we set the category to .ambient and leave the
|
||||
// session INACTIVE — matching the Telegram / Signal / Discord /
|
||||
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
||||
// app's audio session should not be active" while idle.
|
||||
// Configuring .playAndRecord + .voiceChat at launch stops other
|
||||
// apps' music (Spotify, Apple Music, podcasts) the moment the
|
||||
// user opens Chanora, even when they are just reading text chat.
|
||||
//
|
||||
// VoIP configuration is engaged on voice-channel join via the
|
||||
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||
// before `voiceJoin` starts VoiceProcessingIO and again as an
|
||||
// idempotent guard on the AudioStarted lifecycle.
|
||||
// The category itself can be set whenever; only the active
|
||||
// state needs to be deferred. We listen for
|
||||
// didBecomeActiveNotification and activate then. Most
|
||||
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
||||
// this same shape.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "launch-ambient")
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
// Mode rationale (May 2026, .voiceChat reinstated):
|
||||
//
|
||||
// We previously used .default mode after discovering that
|
||||
// .voiceChat routed output through iOS's in-call audio
|
||||
// channel, which made speaker output barely audible. That
|
||||
// bug was caused by cpal's RemoteIO unit binding to a stale
|
||||
// physical transducer — after migrating to coreaudio-rs +
|
||||
// kAudioUnitSubType_VoiceProcessingIO (see
|
||||
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
||||
// binding is correct under either mode because VPIO re-binds
|
||||
// on overrideOutputAudioPort.
|
||||
//
|
||||
// .voiceChat advantages over .default:
|
||||
// * Tells iOS this is a VoIP session — other apps' audio
|
||||
// is properly ducked/paused instead of competing.
|
||||
// * Enables correct Bluetooth HFP negotiation without
|
||||
// manual workarounds.
|
||||
// * iOS treats the audio session as a "call" for priority
|
||||
// purposes (won't be interrupted by notification sounds).
|
||||
// * System-level CallKit integration (lock-screen controls).
|
||||
//
|
||||
// .defaultToSpeaker ensures output goes to the main speaker
|
||||
// (not the earpiece) by default when no headphones are
|
||||
// connected, compensating for the in-call channel's tendency
|
||||
// to route to the earpiece.
|
||||
//
|
||||
// References:
|
||||
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
||||
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
||||
//
|
||||
// Options:
|
||||
// .defaultToSpeaker : route output to the main speaker
|
||||
// (not the earpiece) by default
|
||||
// when no headphones are connected.
|
||||
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
||||
// Profile headsets as both input
|
||||
// and output.
|
||||
// .allowBluetoothA2DP : permit higher-quality A2DP
|
||||
// output-only Bluetooth devices.
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
logAudioSessionState(context: "setCategory")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
|
||||
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
||||
}
|
||||
|
||||
// Activate the session once the app is actually foreground. The
|
||||
// notification fires immediately after the cold-launch settles,
|
||||
// and again on every resume-from-background — both safe
|
||||
// moments to call setActive(true). Repeated activation while
|
||||
// already-active is a no-op per the docs.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(activateAudioSession),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRouteChange(_:)),
|
||||
@@ -79,60 +124,37 @@ import AVFoundation
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// Activate the VoIP audio session. Called from Dart via the
|
||||
/// `chanora/ios_audio_session` channel before a voice channel join
|
||||
/// starts VoiceProcessingIO. Configures
|
||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||
/// repeated calls while already active are a no-op.
|
||||
private func activateVoiceSession() {
|
||||
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
||||
/// every resume-from-background). Activates the AVAudioSession.
|
||||
/// Repeated activation is a no-op when the session is already
|
||||
/// active so this is safe to call on every foreground.
|
||||
@objc private func activateAudioSession() {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
voiceSessionActive = true
|
||||
logAudioSessionState(context: "activateVoiceSession")
|
||||
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
||||
// Read back the ACTUAL session state. preferredSampleRate /
|
||||
// preferredIOBufferDuration are hints; iOS may pick something
|
||||
// else depending on hardware + currently-engaged effects.
|
||||
// Without these we can't tell whether VPIO is running at
|
||||
// 48 kHz mono (what our render callback assumes) or at e.g.
|
||||
// 44.1 kHz (which would explain the user's broken playback
|
||||
// \u2014 our render callback would be writing samples at the
|
||||
// wrong rate, causing pitch + timing artifacts).
|
||||
logAudioSessionState(context: "setActive")
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: voice session active: " +
|
||||
"sampleRate=\(session.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] " +
|
||||
"outputVolume=\(s.outputVolume)"
|
||||
)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
||||
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate the VoIP audio session and return to the idle
|
||||
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
|
||||
/// (intentional leave, disconnect, or connection lost).
|
||||
/// `.notifyOthersOnDeactivation` lets other audio apps know they
|
||||
/// can resume — best-effort: Apple Music / Podcasts resume
|
||||
/// reliably, Spotify is not guaranteed.
|
||||
private func deactivateVoiceSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
} catch {
|
||||
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
|
||||
}
|
||||
do {
|
||||
try session.setCategory(.ambient, mode: .default)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
|
||||
}
|
||||
voiceSessionActive = false
|
||||
logAudioSessionState(context: "deactivateVoiceSession")
|
||||
}
|
||||
|
||||
/// Reads back the actual AVAudioSession state and logs it for
|
||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||
/// to verify that the session accepted the requested configuration.
|
||||
@@ -197,30 +219,25 @@ import AVFoundation
|
||||
}
|
||||
|
||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
||||
if voiceSessionActive {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "mediaServicesWereReset-ambient")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
|
||||
}
|
||||
NSLog("chanora_flutter: media services reset")
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||
}
|
||||
// P1: After rebuilding the session, send the current route class to
|
||||
// Rust so it can recompute the processing policy and reset the
|
||||
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||
// which calls ios_restart_voice_unit.
|
||||
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||
@@ -252,26 +269,6 @@ import AVFoundation
|
||||
name: "chanora/ios_platform",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
iosAudioSessionChannel = FlutterMethodChannel(
|
||||
name: "chanora/ios_audio_session",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activateVoiceSession":
|
||||
self.activateVoiceSession()
|
||||
result(nil)
|
||||
case "deactivateVoiceSession":
|
||||
self.deactivateVoiceSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||
switch call.method {
|
||||
case "getMicrophonePermissionState":
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
<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>
|
||||
|
||||
@@ -242,30 +242,19 @@
|
||||
"clientInfoUnknown": "Unknown",
|
||||
"clientInfoHidden": "Hidden",
|
||||
"clientInfoNone": "None",
|
||||
"pokeSettingsAction": "Poke notifications",
|
||||
"pokeSettingsTitle": "Poke notifications",
|
||||
"pokeSettingsEnableLabel": "Notify me about pokes",
|
||||
"pokeSettingsEnableDescription": "Show local notifications for incoming pokes when this is on.",
|
||||
"pokeSettingsMutedSendersHeader": "Muted senders",
|
||||
"pokeSettingsMutedSendersEmpty": "No muted poke senders.",
|
||||
"pokeSettingsMutedSenderLabel": "Client ID {senderId}",
|
||||
"@pokeSettingsMutedSenderLabel": {
|
||||
"placeholders": {
|
||||
"senderId": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeSettingsUnmuteSenderAction": "Unmute",
|
||||
"pokeOverflowMutePrompt": "Repeated pokes from {sender} were suppressed. Mute this sender?",
|
||||
"@pokeOverflowMutePrompt": {
|
||||
"pokeSnackBarClearAction": "Clear",
|
||||
"pokeSnackBarMoreIndicator": "...",
|
||||
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
|
||||
"@pokeSnackBarIncomingNoMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeOverflowMuteAction": "Mute",
|
||||
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
|
||||
"@pokeMutedSenderConfirmation": {
|
||||
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
|
||||
"@pokeSnackBarIncomingWithMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
"sender": { "type": "String" },
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
||||
|
||||
@@ -191,30 +191,19 @@
|
||||
"clientInfoUnknown": "未知",
|
||||
"clientInfoHidden": "隐藏",
|
||||
"clientInfoNone": "无",
|
||||
"pokeSettingsAction": "戳一戳通知",
|
||||
"pokeSettingsTitle": "戳一戳通知",
|
||||
"pokeSettingsEnableLabel": "接收戳一戳通知",
|
||||
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
|
||||
"pokeSettingsMutedSendersHeader": "已静音的发送者",
|
||||
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
|
||||
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
|
||||
"@pokeSettingsMutedSenderLabel": {
|
||||
"placeholders": {
|
||||
"senderId": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeSettingsUnmuteSenderAction": "取消静音",
|
||||
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
|
||||
"@pokeOverflowMutePrompt": {
|
||||
"pokeSnackBarClearAction": "清除",
|
||||
"pokeSnackBarMoreIndicator": "...",
|
||||
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
|
||||
"@pokeSnackBarIncomingNoMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeOverflowMuteAction": "静音",
|
||||
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
|
||||
"@pokeMutedSenderConfirmation": {
|
||||
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
|
||||
"@pokeSnackBarIncomingWithMessage": {
|
||||
"placeholders": {
|
||||
"sender": { "type": "String" }
|
||||
"sender": { "type": "String" },
|
||||
"message": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
||||
|
||||
@@ -1159,71 +1159,29 @@ abstract class AppL10n {
|
||||
/// **'None'**
|
||||
String get clientInfoNone;
|
||||
|
||||
/// No description provided for @pokeSettingsAction.
|
||||
/// No description provided for @pokeSnackBarClearAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Poke notifications'**
|
||||
String get pokeSettingsAction;
|
||||
/// **'Clear'**
|
||||
String get pokeSnackBarClearAction;
|
||||
|
||||
/// No description provided for @pokeSettingsTitle.
|
||||
/// No description provided for @pokeSnackBarMoreIndicator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Poke notifications'**
|
||||
String get pokeSettingsTitle;
|
||||
/// **'...'**
|
||||
String get pokeSnackBarMoreIndicator;
|
||||
|
||||
/// No description provided for @pokeSettingsEnableLabel.
|
||||
/// No description provided for @pokeSnackBarIncomingNoMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notify me about pokes'**
|
||||
String get pokeSettingsEnableLabel;
|
||||
/// **'{sender} pokes you'**
|
||||
String pokeSnackBarIncomingNoMessage(String sender);
|
||||
|
||||
/// No description provided for @pokeSettingsEnableDescription.
|
||||
/// No description provided for @pokeSnackBarIncomingWithMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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);
|
||||
/// **'{sender} pokes you: {message}'**
|
||||
String pokeSnackBarIncomingWithMessage(String sender, String message);
|
||||
|
||||
/// No description provided for @pokeHistorySelfNoMessage.
|
||||
///
|
||||
|
||||
@@ -590,43 +590,19 @@ class AppL10nEn extends AppL10n {
|
||||
String get clientInfoNone => 'None';
|
||||
|
||||
@override
|
||||
String get pokeSettingsAction => 'Poke notifications';
|
||||
String get pokeSnackBarClearAction => 'Clear';
|
||||
|
||||
@override
|
||||
String get pokeSettingsTitle => 'Poke notifications';
|
||||
String get pokeSnackBarMoreIndicator => '...';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableLabel => 'Notify me about pokes';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableDescription =>
|
||||
'Show local notifications for incoming pokes when this is on.';
|
||||
|
||||
@override
|
||||
String get pokeSettingsMutedSendersHeader => 'Muted senders';
|
||||
|
||||
@override
|
||||
String get pokeSettingsMutedSendersEmpty => 'No muted poke senders.';
|
||||
|
||||
@override
|
||||
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||
return 'Client ID $senderId';
|
||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
||||
return '$sender pokes you';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeSettingsUnmuteSenderAction => 'Unmute';
|
||||
|
||||
@override
|
||||
String pokeOverflowMutePrompt(String sender) {
|
||||
return 'Repeated pokes from $sender were suppressed. Mute this sender?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeOverflowMuteAction => 'Mute';
|
||||
|
||||
@override
|
||||
String pokeMutedSenderConfirmation(String sender) {
|
||||
return 'Muted pokes from $sender';
|
||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
||||
return '$sender pokes you: $message';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -577,42 +577,19 @@ class AppL10nZh extends AppL10n {
|
||||
String get clientInfoNone => '无';
|
||||
|
||||
@override
|
||||
String get pokeSettingsAction => '戳一戳通知';
|
||||
String get pokeSnackBarClearAction => '清除';
|
||||
|
||||
@override
|
||||
String get pokeSettingsTitle => '戳一戳通知';
|
||||
String get pokeSnackBarMoreIndicator => '...';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableLabel => '接收戳一戳通知';
|
||||
|
||||
@override
|
||||
String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。';
|
||||
|
||||
@override
|
||||
String get pokeSettingsMutedSendersHeader => '已静音的发送者';
|
||||
|
||||
@override
|
||||
String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。';
|
||||
|
||||
@override
|
||||
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||
return '用户 ID $senderId';
|
||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
||||
return '$sender 戳了你一下';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeSettingsUnmuteSenderAction => '取消静音';
|
||||
|
||||
@override
|
||||
String pokeOverflowMutePrompt(String sender) {
|
||||
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pokeOverflowMuteAction => '静音';
|
||||
|
||||
@override
|
||||
String pokeMutedSenderConfirmation(String sender) {
|
||||
return '已静音来自 $sender 的戳一戳';
|
||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
||||
return '$sender 戳了你一下:$message';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+173
-149
@@ -22,19 +22,14 @@ import 'l10n/generated/app_localizations.dart';
|
||||
import 'services/android_permissions_service.dart';
|
||||
import 'services/app_bootstrap.dart';
|
||||
import 'services/audio_lifecycle_service.dart';
|
||||
import 'services/ios_audio_session_controller.dart';
|
||||
import 'services/channel_join_error_mapper.dart';
|
||||
import 'services/connection_phase_state.dart';
|
||||
import 'services/hard_mute_owners.dart';
|
||||
import 'services/ios_permissions_service.dart';
|
||||
import 'services/macos_permissions_service.dart';
|
||||
import 'services/poke_notification_service.dart';
|
||||
import 'services/poke_preferences_service.dart';
|
||||
import 'services/prefetch_debouncer.dart';
|
||||
import 'services/snapshot_state_mapper.dart';
|
||||
import 'services/ts3_server_link.dart';
|
||||
import 'services/ui_preferences_service.dart';
|
||||
import 'services/voice_join_ordering.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
@@ -46,7 +41,6 @@ import 'widgets/client_info_sheet.dart';
|
||||
import 'widgets/connect_widgets.dart';
|
||||
import 'widgets/input_dialogs.dart';
|
||||
import 'widgets/permission_state_banner.dart';
|
||||
import 'widgets/poke_notification_settings.dart';
|
||||
import 'widgets/snapshot_view.dart';
|
||||
import 'widgets/voice_platform.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
@@ -93,10 +87,7 @@ String _kAppVersion = appSemverBaseline;
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await RustLib.init();
|
||||
unawaited(() async {
|
||||
await wireStorage();
|
||||
await wireCache();
|
||||
}());
|
||||
unawaited(wireStorage());
|
||||
unawaited(wireConnectivity());
|
||||
wireAudioLifecycle();
|
||||
await configureBundledVadModels();
|
||||
@@ -168,7 +159,10 @@ class _ChanoraAppState extends State<ChanoraApp> {
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Stack(
|
||||
children: [
|
||||
_BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode),
|
||||
_BetaHome(
|
||||
themeMode: _themeMode,
|
||||
onThemeModeChanged: _setThemeMode,
|
||||
),
|
||||
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
|
||||
],
|
||||
),
|
||||
@@ -206,19 +200,6 @@ extension on ThemeMode {
|
||||
}
|
||||
}
|
||||
|
||||
bool isPokeSenderActiveChat({
|
||||
required bool chatOpen,
|
||||
required rust.BridgeMessageTarget? inlineChatTarget,
|
||||
required BigInt senderId,
|
||||
}) {
|
||||
if (!chatOpen) return false;
|
||||
return switch (inlineChatTarget) {
|
||||
rust.BridgeMessageTarget_Poke(:final field0) ||
|
||||
rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
class ChanoraThemeModeMenu extends StatelessWidget {
|
||||
const ChanoraThemeModeMenu({
|
||||
super.key,
|
||||
@@ -319,6 +300,18 @@ class _BetaHome extends StatefulWidget {
|
||||
State<_BetaHome> createState() => _BetaHomeState();
|
||||
}
|
||||
|
||||
class _ReceivedPoke {
|
||||
const _ReceivedPoke({
|
||||
required this.senderName,
|
||||
required this.message,
|
||||
required this.receivedAt,
|
||||
});
|
||||
|
||||
final String senderName;
|
||||
final String message;
|
||||
final DateTime receivedAt;
|
||||
}
|
||||
|
||||
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
||||
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
||||
@@ -345,9 +338,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
bool _inChannel = false;
|
||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||
bool _hardMute = false;
|
||||
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
||||
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
||||
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
||||
bool _hardMuteByPermission = false;
|
||||
bool _hardMuteByTalkPower = false;
|
||||
bool _permissionHardMuteClearInFlight = false;
|
||||
int _releaseTailMs = 200;
|
||||
BigInt? _currentVoiceChannelId;
|
||||
@@ -417,6 +409,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
/// previous conversation when the user reopens chat.
|
||||
rust.BridgeMessageTarget? _lastDismissedTarget;
|
||||
String _lastDismissedClientName = '';
|
||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
||||
const [],
|
||||
);
|
||||
bool _pokeSnackBarVisible = false;
|
||||
|
||||
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
|
||||
// Constructed at startup so cold-launch state is captured before the
|
||||
// first voice_join attempt. On non-Android hosts the service
|
||||
@@ -431,9 +428,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
// MethodChannel.
|
||||
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
|
||||
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
||||
final PokeNotificationService _pokeNotifications = PokeNotificationService();
|
||||
final PokePreferencesService _pokePreferences = PokePreferencesService();
|
||||
late final Future<void> _pokePreferencesReady;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -467,8 +461,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_onMacOSPttCapabilityChanged,
|
||||
);
|
||||
_macOSPermissions.checkInitialStates();
|
||||
unawaited(_pokeNotifications.init());
|
||||
_pokePreferencesReady = _pokePreferences.load();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_requestRecordAudioOnStartup());
|
||||
});
|
||||
@@ -597,8 +589,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: false);
|
||||
if (!mounted || !_hardMuteByPermission) return;
|
||||
setState(() {
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -748,7 +740,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
unawaited(iosAudioSessionController.activate());
|
||||
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true));
|
||||
case rust.BridgeEvent_Lost(:final reason):
|
||||
_recordUiDiagnostic('connection', 'lost: $reason');
|
||||
@@ -774,10 +765,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
||||
});
|
||||
case rust.BridgeEvent_AudioStarted():
|
||||
unawaited(iosAudioSessionController.activate());
|
||||
_ensureStatsTimer();
|
||||
case rust.BridgeEvent_AudioStopped():
|
||||
unawaited(iosAudioSessionController.deactivate());
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
case rust.BridgeEvent_PttCapability(
|
||||
@@ -806,8 +795,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_voiceStateInitialized = true;
|
||||
_inChannel = inChannel;
|
||||
_transmitMode = transmitMode;
|
||||
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = mute;
|
||||
if (!mute) _hardMuteByPermission = false;
|
||||
_releaseTailMs = releaseTailMs;
|
||||
_currentVoiceChannelId = currentChannelId;
|
||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||
@@ -873,11 +862,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
:final senderName,
|
||||
:final message,
|
||||
:final target,
|
||||
:final pokeStrength,
|
||||
):
|
||||
// Skip echo of self-sent messages (already added locally).
|
||||
if (senderId == _snapshot?.ownClientId) return;
|
||||
final isPoke = target is rust.BridgeMessageTarget_Poke;
|
||||
// Skip echo of self-sent non-poke messages (already added locally).
|
||||
if (!isPoke && senderId == _snapshot?.ownClientId) return;
|
||||
final receivedAt = DateTime.now();
|
||||
setState(() {
|
||||
_appendChatEntryUnlocked(
|
||||
@@ -892,13 +880,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
});
|
||||
if (isPoke) {
|
||||
unawaited(
|
||||
_handleIncomingPoke(
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
strength: pokeStrength,
|
||||
),
|
||||
_showPokeSnackBar(
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
receivedAt: receivedAt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1098,6 +1083,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_nickCtl.dispose();
|
||||
_passwordCtl.dispose();
|
||||
_chatFeedRevision.dispose();
|
||||
_pokeSnackBarPokes.dispose();
|
||||
_androidPermissions.recordAudioState.removeListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
);
|
||||
@@ -1114,7 +1100,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_androidPermissions.stop();
|
||||
_iosPermissions.stop();
|
||||
_macOSPermissions.stop();
|
||||
_pokePreferences.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -1148,9 +1133,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
if (accessState == MacOSLocalNetworkState.denied) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_phase = ConnectionPhase.idle;
|
||||
});
|
||||
setState(() { _phase = ConnectionPhase.idle; });
|
||||
_showLocalNetworkDeniedSnackBar();
|
||||
return;
|
||||
}
|
||||
@@ -1162,7 +1145,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_chatMessages.clear();
|
||||
});
|
||||
_releaseFocusedPttIfHeld();
|
||||
await iosAudioSessionController.activate();
|
||||
try {
|
||||
final snap = await rust.connect(
|
||||
host: (host ?? _hostCtl.text).trim(),
|
||||
@@ -1286,8 +1268,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = true;
|
||||
_hardMuteByPermission = true;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -1302,23 +1284,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
await joinVoiceChannelWithIosAudioSession(
|
||||
channelId: ch.id,
|
||||
password: password ?? '',
|
||||
voiceJoin: rust.voiceJoin,
|
||||
activateIosAudioSession: iosAudioSessionController.activate,
|
||||
deactivateIosAudioSession: iosAudioSessionController.deactivate,
|
||||
// Server says we are already in the target channel: the user is
|
||||
// still joined to a voice channel, so the iOS audio session must
|
||||
// stay active. The catch below converts this rethrow into the
|
||||
// success-on-already-joined branch.
|
||||
isJoinSuccess: _isAlreadyInChannel,
|
||||
);
|
||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_currentVoiceChannelId = ch.id;
|
||||
@@ -1382,14 +1353,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final next = !_hardMute;
|
||||
final previousInputMuted = _inputMuted;
|
||||
final previousHardMute = _hardMute;
|
||||
final previousHardMuteOwners = _hardMuteOwners;
|
||||
final previousPermissionMute = _hardMuteByPermission;
|
||||
setState(() {
|
||||
_inputMuted = next;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(
|
||||
manual: next,
|
||||
permission: next ? false : null,
|
||||
);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = next;
|
||||
if (next) {
|
||||
_hardMuteByPermission = false;
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Hard-mute is two coordinated effects:
|
||||
@@ -1408,7 +1378,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
setState(() {
|
||||
_inputMuted = previousInputMuted;
|
||||
_hardMute = previousHardMute;
|
||||
_hardMuteOwners = previousHardMuteOwners;
|
||||
_hardMuteByPermission = previousPermissionMute;
|
||||
});
|
||||
_showUiError('hard mute', e);
|
||||
}
|
||||
@@ -1544,16 +1514,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOpenPokeSettings() async {
|
||||
await _pokePreferences.load();
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) =>
|
||||
PokeNotificationSettingsDialog(preferences: _pokePreferences),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
||||
// Same pattern as _onAddCurrentBookmark: route the dialog
|
||||
// through a dedicated StatefulWidget so its
|
||||
@@ -1688,7 +1648,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_inputMuted = false;
|
||||
_outputMuted = false;
|
||||
_hardMute = false;
|
||||
_hardMuteOwners = const HardMuteOwners();
|
||||
_hardMuteByPermission = false;
|
||||
_hardMuteByTalkPower = false;
|
||||
_inChannel = false;
|
||||
_currentVoiceChannelId = null;
|
||||
_pendingVoiceChannelId = null;
|
||||
@@ -1838,7 +1799,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _inlineChatTarget == null) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)),
|
||||
SnackBar(
|
||||
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1859,68 +1822,52 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleIncomingPoke({
|
||||
required BigInt senderId,
|
||||
void _showPokeSnackBar({
|
||||
required String senderName,
|
||||
required String message,
|
||||
required rust.BridgePokeStrength? strength,
|
||||
}) async {
|
||||
if (senderId == _snapshot?.ownClientId) return;
|
||||
await _pokePreferencesReady;
|
||||
if (!_pokePreferences.pokesEnabled.value) return;
|
||||
if (_pokePreferences.isMuted(senderId)) return;
|
||||
final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed;
|
||||
if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) {
|
||||
_showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName);
|
||||
}
|
||||
if (_isPokeSenderActiveChat(senderId)) return;
|
||||
await _pokeNotifications.show(
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
senderId: senderId,
|
||||
strength: pokeStrength,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPokeSenderActiveChat(BigInt senderId) {
|
||||
return isPokeSenderActiveChat(
|
||||
chatOpen: _chatOpen,
|
||||
inlineChatTarget: _inlineChatTarget,
|
||||
senderId: senderId,
|
||||
);
|
||||
}
|
||||
|
||||
void _showPokeOverflowMutePrompt({
|
||||
required BigInt senderId,
|
||||
required String senderName,
|
||||
required DateTime receivedAt,
|
||||
}) {
|
||||
final l10n = AppL10n.of(context);
|
||||
_pokeSnackBarPokes.value = [
|
||||
..._pokeSnackBarPokes.value,
|
||||
_ReceivedPoke(
|
||||
senderName: senderName,
|
||||
message: message,
|
||||
receivedAt: receivedAt,
|
||||
),
|
||||
];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _renderPokeSnackBar();
|
||||
});
|
||||
}
|
||||
|
||||
void _renderPokeSnackBar() {
|
||||
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
|
||||
_pokeSnackBarVisible = true;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
final controller = messenger.showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: _chatSnackBarMargin(),
|
||||
duration: const Duration(seconds: 8),
|
||||
content: Text(
|
||||
l10n.pokeOverflowMutePrompt(senderName),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
duration: const Duration(days: 365),
|
||||
dismissDirection: DismissDirection.none,
|
||||
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
|
||||
action: SnackBarAction(
|
||||
label: l10n.pokeOverflowMuteAction,
|
||||
label: AppL10n.of(context).pokeSnackBarClearAction,
|
||||
onPressed: () {
|
||||
unawaited(_pokePreferences.muteSender(senderId));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: _chatSnackBarMargin(),
|
||||
content: Text(l10n.pokeMutedSenderConfirmation(senderName)),
|
||||
),
|
||||
);
|
||||
_pokeSnackBarPokes.value = const [];
|
||||
_pokeSnackBarVisible = false;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
controller.closed.then((_) {
|
||||
if (!mounted) return;
|
||||
_pokeSnackBarVisible = false;
|
||||
if (_pokeSnackBarPokes.value.isEmpty) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _renderPokeSnackBar();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _showChatMessageSnackBar({
|
||||
@@ -1928,6 +1875,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
required String message,
|
||||
required rust.BridgeMessageTarget target,
|
||||
}) {
|
||||
if (_pokeSnackBarPokes.value.isNotEmpty) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.hideCurrentSnackBar();
|
||||
messenger.showSnackBar(
|
||||
@@ -2132,8 +2080,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||
final talkPowerEpoch = _connectionEpoch;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMuteByTalkPower = true;
|
||||
_hardMute = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
_connectionEpoch != talkPowerEpoch ||
|
||||
@@ -2146,14 +2094,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||
final talkPowerEpoch = _connectionEpoch;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
if (!_hardMuteOwners.effective) {
|
||||
_hardMuteByTalkPower = false;
|
||||
if (!_hardMuteByPermission) {
|
||||
_hardMute = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
_connectionEpoch != talkPowerEpoch ||
|
||||
!_serverReachable ||
|
||||
_hardMuteOwners.effective) {
|
||||
_hardMuteByTalkPower ||
|
||||
_hardMuteByPermission) {
|
||||
return;
|
||||
}
|
||||
unawaited(rust.setHardMute(muted: false));
|
||||
@@ -2424,11 +2373,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
themeMode: widget.themeMode,
|
||||
onThemeModeChanged: widget.onThemeModeChanged,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.pokeSettingsAction,
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () => unawaited(_onOpenPokeSettings()),
|
||||
),
|
||||
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
@@ -2908,3 +2852,83 @@ class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PokeSnackBarContent extends StatelessWidget {
|
||||
const _PokeSnackBarContent({required this.pokes});
|
||||
|
||||
final ValueListenable<List<_ReceivedPoke>> pokes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<List<_ReceivedPoke>>(
|
||||
valueListenable: pokes,
|
||||
builder: (context, entries, _) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final visible = entries.length <= 3
|
||||
? entries
|
||||
: entries.sublist(entries.length - 3);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (entries.length > 3)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 2),
|
||||
child: Text(
|
||||
l10n.pokeSnackBarMoreIndicator,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
for (final poke in visible) _PokeSnackBarRow(poke: poke),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PokeSnackBarRow extends StatelessWidget {
|
||||
const _PokeSnackBarRow({required this.poke});
|
||||
|
||||
final _ReceivedPoke poke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final message = poke.message.trim();
|
||||
final text = message.isEmpty
|
||||
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
|
||||
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
pokeSnackBarTimeLabel(poke.receivedAt),
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String pokeSnackBarTimeLabel(DateTime timestamp) {
|
||||
String two(int value) => value.toString().padLeft(2, '0');
|
||||
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
|
||||
}
|
||||
|
||||
@@ -15,22 +15,15 @@ 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,
|
||||
@@ -128,50 +121,16 @@ 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) {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
class HardMuteOwners {
|
||||
const HardMuteOwners({
|
||||
this.manual = false,
|
||||
this.permission = false,
|
||||
this.talkPower = false,
|
||||
});
|
||||
|
||||
final bool manual;
|
||||
final bool permission;
|
||||
final bool talkPower;
|
||||
|
||||
bool get effective => manual || permission || talkPower;
|
||||
|
||||
HardMuteOwners withBridgeManualMute(bool muted) {
|
||||
return copyWith(
|
||||
manual: muted && (manual || !permission && !talkPower),
|
||||
);
|
||||
}
|
||||
|
||||
HardMuteOwners copyWith({
|
||||
bool? manual,
|
||||
bool? permission,
|
||||
bool? talkPower,
|
||||
}) {
|
||||
return HardMuteOwners(
|
||||
manual: manual ?? this.manual,
|
||||
permission: permission ?? this.permission,
|
||||
talkPower: talkPower ?? this.talkPower,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||
|
||||
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
||||
///
|
||||
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
||||
/// launch and leaves it inactive. The session is only switched to
|
||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||
/// voice channel is actually active. This controller is the Dart
|
||||
/// side of that contract — call [activate] before the Rust engine
|
||||
/// starts VoiceProcessingIO and [deactivate] on
|
||||
/// `BridgeEvent::AudioStopped` or failed joins.
|
||||
///
|
||||
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||
/// handle their own session lifecycle elsewhere (Android via
|
||||
/// `AndroidAudioLifecycleController`, macOS via
|
||||
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
|
||||
class IosAudioSessionController {
|
||||
IosAudioSessionController({
|
||||
MethodChannel? channel,
|
||||
bool? isIos,
|
||||
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
|
||||
_isIos = isIos ?? Platform.isIOS;
|
||||
|
||||
final MethodChannel _channel;
|
||||
final bool _isIos;
|
||||
|
||||
Future<void> activate() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('activateVoiceSession');
|
||||
} on PlatformException {
|
||||
// Swift side logs the failure via NSLog; surfacing the
|
||||
// exception to the event handler would be noise. The Rust
|
||||
// engine remains alive and will produce silence until the
|
||||
// next route change or a manual leave/rejoin.
|
||||
} on MissingPluginException {
|
||||
// Test hosts and mispackaged builds may not have registered
|
||||
// the iOS channel. Keep event dispatch alive rather than
|
||||
// surfacing an unhandled async error.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivate() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('deactivateVoiceSession');
|
||||
} on PlatformException {
|
||||
// Same rationale as activate(): the Swift side logs.
|
||||
// Worst case the session stays in .playAndRecord until the
|
||||
// app is backgrounded — at which point iOS reclaims the
|
||||
// session automatically.
|
||||
} on MissingPluginException {
|
||||
// Same rationale as activate(): missing channel should not
|
||||
// break bridge event handling.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default singleton used by [main.dart] event dispatch. Tests
|
||||
/// should construct their own [IosAudioSessionController] with a
|
||||
/// mocked channel rather than mutating this instance.
|
||||
final iosAudioSessionController = IosAudioSessionController();
|
||||
@@ -1,179 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
class PokeNotificationService {
|
||||
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
|
||||
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
static const _strongAndroidChannelId = 'chanora_pokes_strong_v1';
|
||||
static const _defaultAndroidChannelId = 'chanora_pokes_default_v1';
|
||||
static const _groupKey = 'chanora.pokes';
|
||||
static const _darwinThreadId = 'chanora.pokes';
|
||||
static const _windowsHeader = WindowsHeader(
|
||||
id: 'chanora.pokes',
|
||||
title: 'Pokes',
|
||||
arguments: 'pokes',
|
||||
);
|
||||
static const _windowsAppUserModelId = 'Chanora.Client';
|
||||
static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E';
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications;
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
await _notifications.initialize(
|
||||
settings: const InitializationSettings(
|
||||
android: AndroidInitializationSettings('ic_chanora_notification'),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
requestSoundPermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
defaultPresentSound: false,
|
||||
),
|
||||
macOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
requestSoundPermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
defaultPresentSound: false,
|
||||
),
|
||||
linux: LinuxInitializationSettings(
|
||||
defaultActionName: 'Open',
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
defaultSuppressSound: true,
|
||||
),
|
||||
windows: WindowsInitializationSettings(
|
||||
appName: 'Chanora',
|
||||
appUserModelId: _windowsAppUserModelId,
|
||||
guid: _windowsGuid,
|
||||
),
|
||||
),
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<bool> requestPermission() async {
|
||||
await init();
|
||||
if (kIsWeb) return true;
|
||||
|
||||
final android = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (android != null) {
|
||||
return await android.requestNotificationsPermission() ?? true;
|
||||
}
|
||||
|
||||
final ios = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (ios != null) {
|
||||
return await ios.requestPermissions(alert: true, badge: true) ?? false;
|
||||
}
|
||||
|
||||
final macOS = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (macOS != null) {
|
||||
return await macOS.requestPermissions(alert: true, badge: true) ?? false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> show({
|
||||
required String senderName,
|
||||
required String message,
|
||||
required BigInt senderId,
|
||||
required rust.BridgePokeStrength strength,
|
||||
}) async {
|
||||
await init();
|
||||
final permitted = await requestPermission();
|
||||
if (!permitted) return;
|
||||
|
||||
final trimmedMessage = message.trim();
|
||||
final body = trimmedMessage.isEmpty
|
||||
? '$senderName pokes you'
|
||||
: trimmedMessage;
|
||||
|
||||
await _notifications.show(
|
||||
id: senderId.toUnsigned(31).toInt(),
|
||||
title: 'Poke from $senderName',
|
||||
body: body,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: _androidDetails(strength),
|
||||
iOS: _darwinDetails(strength),
|
||||
macOS: _darwinDetails(strength),
|
||||
linux: _linuxDetails(strength),
|
||||
windows: _windowsDetails(strength),
|
||||
),
|
||||
payload: 'poke:$senderId',
|
||||
);
|
||||
}
|
||||
|
||||
AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) {
|
||||
final isStrong = strength == rust.BridgePokeStrength.strong;
|
||||
return AndroidNotificationDetails(
|
||||
isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId,
|
||||
isStrong ? 'Pokes' : 'Pokes (quiet)',
|
||||
channelDescription: 'TeamSpeak poke notifications',
|
||||
importance: isStrong ? Importance.max : Importance.defaultImportance,
|
||||
priority: isStrong ? Priority.high : Priority.defaultPriority,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
playSound: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
silent: true,
|
||||
groupKey: _groupKey,
|
||||
category: AndroidNotificationCategory.message,
|
||||
visibility: NotificationVisibility.private,
|
||||
);
|
||||
}
|
||||
|
||||
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
||||
return DarwinNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
presentSound: false,
|
||||
threadIdentifier: _darwinThreadId,
|
||||
interruptionLevel: switch (strength) {
|
||||
rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive,
|
||||
rust.BridgePokeStrength.suppressed => InterruptionLevel.active,
|
||||
rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
|
||||
return LinuxNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
suppressSound: true,
|
||||
urgency: switch (strength) {
|
||||
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
|
||||
rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal,
|
||||
rust.BridgePokeStrength.suppressedOverflow =>
|
||||
LinuxNotificationUrgency.low,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
|
||||
return WindowsNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
audio: WindowsNotificationAudio.silent(),
|
||||
header: _windowsHeader,
|
||||
scenario: strength == rust.BridgePokeStrength.strong
|
||||
? WindowsNotificationScenario.urgent
|
||||
: null,
|
||||
duration: strength == rust.BridgePokeStrength.strong
|
||||
? WindowsNotificationDuration.long
|
||||
: WindowsNotificationDuration.short,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class PokePreferencesService {
|
||||
static const _enabledKey = 'pokes.enabled';
|
||||
static const _mutedSendersKey = 'pokes.muted_senders';
|
||||
|
||||
final ValueNotifier<bool> _pokesEnabled = ValueNotifier<bool>(true);
|
||||
final ValueNotifier<Set<BigInt>> _mutedSenders = ValueNotifier<Set<BigInt>>(
|
||||
const <BigInt>{},
|
||||
);
|
||||
|
||||
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
||||
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
||||
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
|
||||
_mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const [])
|
||||
.map(BigInt.parse)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<void> setPokesEnabled(bool enabled) async {
|
||||
_pokesEnabled.value = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_enabledKey, enabled);
|
||||
}
|
||||
|
||||
Future<void> muteSender(BigInt senderId) async {
|
||||
if (_mutedSenders.value.contains(senderId)) return;
|
||||
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
||||
await _saveMutedSenders();
|
||||
}
|
||||
|
||||
Future<void> unmuteSender(BigInt senderId) async {
|
||||
if (!_mutedSenders.value.contains(senderId)) return;
|
||||
_mutedSenders.value = _mutedSenders.value
|
||||
.where((mutedSender) => mutedSender != senderId)
|
||||
.toSet();
|
||||
await _saveMutedSenders();
|
||||
}
|
||||
|
||||
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
|
||||
|
||||
Future<void> _saveMutedSenders() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_mutedSendersKey,
|
||||
_mutedSenders.value.map((senderId) => senderId.toString()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_pokesEnabled.dispose();
|
||||
_mutedSenders.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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`, `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 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 functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||
|
||||
/// Return the platform-conventional log-file path as a string, or
|
||||
@@ -226,29 +226,6 @@ 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();
|
||||
@@ -286,8 +263,7 @@ 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, the session is dropped, or
|
||||
/// the session becomes persistently unavailable.
|
||||
/// when the Dart subscriber cancels or the session is dropped.
|
||||
Stream<double> inputLevelStream() =>
|
||||
RustLib.instance.api.crateApiInputLevelStream();
|
||||
|
||||
@@ -1233,9 +1209,6 @@ 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.
|
||||
@@ -1246,124 +1219,59 @@ 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;
|
||||
|
||||
/// A channel was deleted.
|
||||
const factory BridgeEvent.channelRemoved({
|
||||
/// Channel identifier.
|
||||
required BigInt id,
|
||||
}) = BridgeEvent_ChannelRemoved;
|
||||
|
||||
/// Channel properties changed.
|
||||
const factory BridgeEvent.channelRemoved({required BigInt id}) =
|
||||
BridgeEvent_ChannelRemoved;
|
||||
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 {
|
||||
/// Apple VoiceProcessingIO path.
|
||||
/// Shipping VPIO path.
|
||||
platformVoiceProcessing,
|
||||
|
||||
/// Experimental Sonora path.
|
||||
sonoraExperimental,
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -1398,18 +1306,6 @@ 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, 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;
|
||||
@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;
|
||||
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,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);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, 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;
|
||||
@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;
|
||||
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,_that.pokeStrength);case BridgeEvent_ServerActivity():
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);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, 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;
|
||||
@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;
|
||||
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,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);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, this.pokeStrength}): super._();
|
||||
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
|
||||
|
||||
|
||||
/// Client id of the sender.
|
||||
@@ -940,8 +940,6 @@ 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.
|
||||
@@ -953,16 +951,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)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength);
|
||||
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)';
|
||||
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
|
||||
}
|
||||
|
||||
|
||||
@@ -973,7 +971,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, BridgePokeStrength? pokeStrength
|
||||
BigInt senderId, String senderName, String message, BridgeMessageTarget target
|
||||
});
|
||||
|
||||
|
||||
@@ -990,14 +988,13 @@ 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,Object? pokeStrength = freezed,}) {
|
||||
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
|
||||
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,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable
|
||||
as BridgePokeStrength?,
|
||||
as BridgeMessageTarget,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1087,7 +1084,6 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent {
|
||||
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
||||
|
||||
|
||||
/// New audio output route.
|
||||
final BridgeAudioRoute route;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1154,9 +1150,7 @@ 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
|
||||
@@ -1224,21 +1218,13 @@ 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
|
||||
@@ -1312,9 +1298,7 @@ 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
|
||||
@@ -1382,17 +1366,11 @@ 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
|
||||
@@ -1464,18 +1442,11 @@ 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
|
||||
@@ -1547,7 +1518,6 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent {
|
||||
const BridgeEvent_ChannelRemoved({required this.id}): super._();
|
||||
|
||||
|
||||
/// Channel identifier.
|
||||
final BigInt id;
|
||||
|
||||
/// Create a copy of BridgeEvent
|
||||
@@ -1614,13 +1584,9 @@ 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 => 635684021;
|
||||
int get rustContentHash => -20394775;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -87,8 +87,6 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiBridgeInit();
|
||||
|
||||
Future<void> crateApiClearFileCache();
|
||||
|
||||
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
||||
|
||||
Future<BridgeSnapshot> crateApiConnect({
|
||||
@@ -101,21 +99,12 @@ 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();
|
||||
@@ -132,8 +121,6 @@ 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();
|
||||
@@ -333,33 +320,6 @@ 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,
|
||||
@@ -372,7 +332,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 6,
|
||||
funcId: 5,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -406,7 +366,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 7,
|
||||
funcId: 6,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -436,7 +396,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
funcId: 7,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -463,7 +423,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 9,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -481,68 +441,6 @@ 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(
|
||||
@@ -553,7 +451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 12,
|
||||
funcId: 9,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -586,7 +484,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 13,
|
||||
funcId: 10,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -612,7 +510,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -628,33 +526,6 @@ 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(
|
||||
@@ -664,7 +535,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 16,
|
||||
funcId: 12,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -694,7 +565,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 17,
|
||||
funcId: 13,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -721,7 +592,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 18,
|
||||
funcId: 14,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -748,7 +619,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 19,
|
||||
funcId: 15,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -772,7 +643,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -795,7 +666,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(shouldResume, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -821,7 +692,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(routeClass, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -847,7 +718,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -865,34 +736,6 @@ 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(
|
||||
@@ -903,7 +746,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 25,
|
||||
funcId: 20,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -933,7 +776,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 26,
|
||||
funcId: 21,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -962,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
funcId: 22,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -989,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 28,
|
||||
funcId: 23,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1016,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 29,
|
||||
funcId: 24,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1040,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -1070,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 31,
|
||||
funcId: 26,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1100,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 32,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1127,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 33,
|
||||
funcId: 28,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1152,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1185,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 35,
|
||||
funcId: 30,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1212,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1246,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 37,
|
||||
funcId: 32,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1281,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 38,
|
||||
funcId: 33,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1311,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 39,
|
||||
funcId: 34,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1339,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 40,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1367,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 41,
|
||||
funcId: 36,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1397,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 42,
|
||||
funcId: 37,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1425,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_network_state(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1451,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 44,
|
||||
funcId: 39,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1479,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 45,
|
||||
funcId: 40,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1507,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 46,
|
||||
funcId: 41,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1535,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 47,
|
||||
funcId: 42,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1567,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 48,
|
||||
funcId: 43,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1597,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 49,
|
||||
funcId: 44,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1625,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 50,
|
||||
funcId: 45,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1653,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 51,
|
||||
funcId: 46,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1680,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 52,
|
||||
funcId: 47,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1708,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 53,
|
||||
funcId: 48,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1740,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 54,
|
||||
funcId: 49,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1769,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 55,
|
||||
funcId: 50,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1840,12 +1683,6 @@ 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,
|
||||
@@ -2170,7 +2007,6 @@ 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]));
|
||||
@@ -2262,12 +2098,6 @@ 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
|
||||
@@ -2404,16 +2234,6 @@ 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) {
|
||||
@@ -2447,12 +2267,6 @@ 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
|
||||
@@ -2544,14 +2358,6 @@ 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,
|
||||
@@ -3003,15 +2809,11 @@ 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);
|
||||
@@ -3139,15 +2941,6 @@ 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
|
||||
@@ -3339,19 +3132,6 @@ 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(
|
||||
@@ -3412,17 +3192,6 @@ 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,
|
||||
@@ -3537,15 +3306,6 @@ 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,
|
||||
@@ -3884,17 +3644,12 @@ 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);
|
||||
@@ -4016,15 +3771,6 @@ 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,
|
||||
@@ -4201,19 +3947,6 @@ 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,
|
||||
@@ -4270,19 +4003,6 @@ 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,9 +46,6 @@ 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,
|
||||
@@ -123,9 +120,6 @@ 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);
|
||||
|
||||
@@ -180,11 +174,6 @@ 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);
|
||||
@@ -201,9 +190,6 @@ 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);
|
||||
|
||||
@@ -254,11 +240,6 @@ 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,
|
||||
@@ -347,11 +328,6 @@ 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);
|
||||
|
||||
@@ -424,11 +400,6 @@ 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(
|
||||
@@ -447,9 +418,6 @@ 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,
|
||||
@@ -509,12 +477,6 @@ 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,
|
||||
@@ -626,12 +588,6 @@ 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,
|
||||
@@ -725,12 +681,6 @@ 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,
|
||||
@@ -752,12 +702,6 @@ 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,9 +48,6 @@ 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,
|
||||
@@ -125,9 +122,6 @@ 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);
|
||||
|
||||
@@ -182,11 +176,6 @@ 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);
|
||||
@@ -203,9 +192,6 @@ 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);
|
||||
|
||||
@@ -256,11 +242,6 @@ 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,
|
||||
@@ -349,11 +330,6 @@ 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);
|
||||
|
||||
@@ -426,11 +402,6 @@ 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(
|
||||
@@ -449,9 +420,6 @@ 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,
|
||||
@@ -511,12 +479,6 @@ 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,
|
||||
@@ -628,12 +590,6 @@ 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,
|
||||
@@ -727,12 +683,6 @@ 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,
|
||||
@@ -754,12 +704,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_list_prim_u_8_strict(
|
||||
Uint8List? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_permission_state_kind(
|
||||
PermissionStateKind self,
|
||||
|
||||
@@ -15,12 +15,6 @@ 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.
|
||||
@@ -503,7 +497,7 @@ String chatInputPlaceholder(
|
||||
case rust.BridgeMessageTarget_Client():
|
||||
return 'Message $clientName...';
|
||||
case rust.BridgeMessageTarget_Poke():
|
||||
return 'Poke message optional...';
|
||||
return 'Poke message...';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,16 +513,6 @@ 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,
|
||||
@@ -1082,7 +1066,6 @@ class ChatDetailView extends StatefulWidget {
|
||||
this.messageMaxWidth,
|
||||
this.restoredDraft,
|
||||
this.onDraftChanged,
|
||||
this.sendChatMessage,
|
||||
});
|
||||
|
||||
/// Chat target displayed by this detail view.
|
||||
@@ -1118,9 +1101,6 @@ 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();
|
||||
}
|
||||
@@ -1188,12 +1168,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
|
||||
void _send() {
|
||||
final text = _textCtl.text.trim();
|
||||
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
|
||||
return;
|
||||
}
|
||||
if (text.isEmpty || !_canSend) return;
|
||||
_textCtl.clear();
|
||||
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
|
||||
unawaited(sendChatMessage(message: text, target: widget.target));
|
||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
@@ -1246,9 +1223,6 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
channelName: widget.channelName,
|
||||
clientName: widget.clientName,
|
||||
);
|
||||
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
|
||||
? 'Poke'
|
||||
: 'Send';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -1358,7 +1332,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _send,
|
||||
tooltip: sendTooltip,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/poke_preferences_service.dart';
|
||||
import 'voice_settings_controls.dart';
|
||||
|
||||
class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||
const PokeNotificationSettingsDialog({super.key, required this.preferences});
|
||||
|
||||
final PokePreferencesService preferences;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.pokeSettingsTitle),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: preferences.pokesEnabled,
|
||||
builder: (context, enabled, _) => SwitchListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.pokeSettingsEnableLabel),
|
||||
subtitle: Text(l10n.pokeSettingsEnableDescription),
|
||||
value: enabled,
|
||||
onChanged: (value) => preferences.setPokesEnabled(value),
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
VoiceSectionHeader(l10n.pokeSettingsMutedSendersHeader),
|
||||
ValueListenableBuilder<Set<BigInt>>(
|
||||
valueListenable: preferences.mutedSenders,
|
||||
builder: (context, mutedSenders, _) {
|
||||
final senders = mutedSenders.toList()..sort();
|
||||
if (senders.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: Text(
|
||||
l10n.pokeSettingsMutedSendersEmpty,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final senderId in senders)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.notifications_off_outlined),
|
||||
title: Text(
|
||||
l10n.pokeSettingsMutedSenderLabel(
|
||||
senderId.toString(),
|
||||
),
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: () => preferences.unmuteSender(senderId),
|
||||
child: Text(l10n.pokeSettingsUnmuteSenderAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -318,15 +318,6 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
||||
playVoicePttHaptic(held);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_pressed) {
|
||||
_pressed = false;
|
||||
widget.onHeldChanged(false);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -638,19 +629,12 @@ 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,
|
||||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||
),
|
||||
_ModeRow(
|
||||
label: l10n.voiceModeVoiceActivity,
|
||||
icon: Icons.graphic_eq,
|
||||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||
),
|
||||
|
||||
// 3) Release-tail slider (PTT only).
|
||||
if (isPtt) ...[
|
||||
|
||||
@@ -128,12 +128,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
VoiceSectionHeader(l10n.voiceModeLabel),
|
||||
SegmentedButton<rust.BridgeTransmitMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
// DEC-030: hide the voice-activity segment on hosts
|
||||
// that ship no Chanora-owned VAD pipeline (iOS,
|
||||
// macOS, web).
|
||||
segments: transmitModeSegmentsFor(
|
||||
voiceActivityAvailable: voiceActivityTransmitAvailable,
|
||||
),
|
||||
segments: transmitModeSegments,
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
@@ -13,11 +10,7 @@ ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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].
|
||||
/// Transmit mode selector segments.
|
||||
const transmitModeSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
@@ -36,43 +29,6 @@ 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(
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
@@ -42,8 +42,6 @@
|
||||
<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>
|
||||
|
||||
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "96.0.0"
|
||||
version: "93.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "10.0.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -133,10 +133,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -197,10 +197,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.13"
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -262,46 +262,6 @@ 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
|
||||
@@ -361,18 +321,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: haptic_kit
|
||||
sha256: "457f825a3413be2651954639bed27bb2987570f75d90c4e8e1cb9be62db2e59d"
|
||||
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "1.0.3"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -509,6 +469,14 @@ 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:
|
||||
@@ -521,10 +489,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
version: "9.3.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -697,10 +665,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.25"
|
||||
version: "2.4.23"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -826,14 +794,6 @@ 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:
|
||||
@@ -854,10 +814,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.32"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -966,10 +926,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
version: "6.2.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -982,10 +942,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -995,5 +955,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
dart: ">=3.11.5 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -75,7 +75,6 @@ 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,18 +31,6 @@ 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
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
||||
|
||||
void main() {
|
||||
group('HardMuteOwners', () {
|
||||
test('manual mute survives talk-power block and restore', () {
|
||||
const owners = HardMuteOwners(manual: true);
|
||||
|
||||
final blocked = owners.copyWith(talkPower: true);
|
||||
expect(blocked.effective, isTrue);
|
||||
|
||||
final restored = blocked.copyWith(talkPower: false);
|
||||
expect(restored.manual, isTrue);
|
||||
expect(restored.talkPower, isFalse);
|
||||
expect(restored.effective, isTrue);
|
||||
});
|
||||
|
||||
test('effective mute is the union of independent owners', () {
|
||||
expect(const HardMuteOwners().effective, isFalse);
|
||||
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
||||
});
|
||||
|
||||
test('bridge mute does not convert talk-power owner into manual owner', () {
|
||||
const owners = HardMuteOwners(talkPower: true);
|
||||
|
||||
final synced = owners.withBridgeManualMute(true);
|
||||
|
||||
expect(synced.manual, isFalse);
|
||||
expect(synced.talkPower, isTrue);
|
||||
expect(synced.effective, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('IosAudioSessionController', () {
|
||||
const channel = MethodChannel(iosAudioSessionChannelName);
|
||||
final messenger = TestDefaultBinaryMessengerBinding
|
||||
.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('channel name matches Swift contract', () {
|
||||
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
|
||||
});
|
||||
|
||||
test('activate invokes activateVoiceSession on iOS', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
await controller.activate();
|
||||
|
||||
expect(calls.map((c) => c.method), ['activateVoiceSession']);
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
|
||||
test('deactivate invokes deactivateVoiceSession on iOS', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
await controller.deactivate();
|
||||
|
||||
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
|
||||
test('activate is a no-op on non-iOS platforms', () async {
|
||||
var invoked = false;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
invoked = true;
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: false,
|
||||
);
|
||||
await controller.activate();
|
||||
await controller.deactivate();
|
||||
|
||||
expect(invoked, isFalse);
|
||||
});
|
||||
|
||||
test('activate swallows PlatformException so engine keeps running',
|
||||
() async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
throw PlatformException(code: 'avaudiosession_failed');
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
|
||||
test('activate swallows MissingPluginException when channel is absent',
|
||||
() async {
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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 optional...',
|
||||
'Poke message...',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -737,179 +737,6 @@ 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),
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('toggles poke notifications and unmutes senders', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PokePreferencesService();
|
||||
await preferences.load();
|
||||
await preferences.muteSender(BigInt.from(42));
|
||||
addTearDown(preferences.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: PokeNotificationSettingsDialog(preferences: preferences),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Poke notifications'), findsOneWidget);
|
||||
expect(find.text('Client ID 42'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
expect(preferences.pokesEnabled.value, isFalse);
|
||||
|
||||
await tester.tap(find.text('Unmute'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(preferences.isMuted(BigInt.from(42)), isFalse);
|
||||
expect(find.text('No muted poke senders.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
||||
final heldChanges = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(
|
||||
active: false,
|
||||
onHeldChanged: heldChanges.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
|
||||
expect(heldChanges, [true]);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
|
||||
await gesture.cancel();
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
});
|
||||
}
|
||||
@@ -13,24 +13,6 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
test('gated transmit mode segments drop VAD when unsupported', () {
|
||||
expect(
|
||||
transmitModeSegmentsFor(voiceActivityAvailable: false).map((s) => s.value),
|
||||
[rust.BridgeTransmitMode.ptt, rust.BridgeTransmitMode.continuous],
|
||||
);
|
||||
});
|
||||
|
||||
test('gated transmit mode segments include VAD when supported', () {
|
||||
expect(
|
||||
transmitModeSegmentsFor(voiceActivityAvailable: true).map((s) => s.value),
|
||||
[
|
||||
rust.BridgeTransmitMode.ptt,
|
||||
rust.BridgeTransmitMode.continuous,
|
||||
rust.BridgeTransmitMode.voiceActivity,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('shared Android processing segments expose hardware and WebRTC', () {
|
||||
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
jni
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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" }
|
||||
@@ -19,7 +18,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
use chanora_audio::{AudioRoute, PttBackendDescriptor};
|
||||
use chanora_protocol::{MessageTarget, PokeStrength};
|
||||
|
||||
/// Privacy-safe snapshot of the active PTT capability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PttDescriptorSnapshot {
|
||||
/// Stable capability level name.
|
||||
pub level: String,
|
||||
/// Stable backend identifier.
|
||||
pub backend_id: String,
|
||||
/// Coarse bound input class; empty when no binding is active.
|
||||
pub bound_input_class: String,
|
||||
}
|
||||
|
||||
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||
fn from(desc: PttBackendDescriptor) -> Self {
|
||||
Self {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted PTT binding state exposed to callers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedPttBinding {
|
||||
/// Stable input category string (`""`, `"keyboard"`, or
|
||||
/// `"mouse-side-button"`).
|
||||
pub input_class: String,
|
||||
/// Display-only key label; empty when no binding is active.
|
||||
pub key_label: String,
|
||||
}
|
||||
|
||||
impl PersistedPttBinding {
|
||||
pub(crate) fn empty() -> Self {
|
||||
Self {
|
||||
input_class: String::new(),
|
||||
key_label: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level lifecycle event surfaced to subscribers.
|
||||
///
|
||||
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||
/// full event catalogue lands in A.4.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionEvent {
|
||||
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported in the snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; the supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Supervisor gave up after `attempt` failed retries (or the
|
||||
/// user explicitly disconnected mid-outage).
|
||||
Disconnected {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started (e.g. after a successful reconnect with
|
||||
/// reattachment).
|
||||
AudioStarted,
|
||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||
/// explicit user action).
|
||||
AudioStopped,
|
||||
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
||||
/// DEC-023..028). Published when the audio engine starts or
|
||||
/// when the active backend transitions (for example macOS
|
||||
/// permission state change). Carries only the privacy-safe
|
||||
/// descriptor — capability level, backend identifier, bound
|
||||
/// input class — per SRS-202 / DEC-027.
|
||||
PttCapability {
|
||||
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
||||
level: String,
|
||||
/// Stable backend identifier (e.g. `"focused"`).
|
||||
backend_id: String,
|
||||
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
||||
/// no binding is active.
|
||||
bound_input_class: String,
|
||||
},
|
||||
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
||||
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
||||
/// hard-mute toggles, and release-tail edits.
|
||||
VoiceState {
|
||||
/// True when the user has joined a voice channel via
|
||||
/// `voice_join` and the audio engine is running.
|
||||
in_channel: bool,
|
||||
/// Active transmit mode encoded as
|
||||
/// [`chanora_audio::TransmitMode::as_u8`].
|
||||
transmit_mode: u8,
|
||||
/// True when the hard-mute clamp is engaged.
|
||||
mute: bool,
|
||||
/// Current release-tail in milliseconds (0..=500).
|
||||
release_tail_ms: u32,
|
||||
/// Last confirmed authoritative channel id from the
|
||||
/// `channel_join` reducer projection.
|
||||
current_channel_id: Option<u64>,
|
||||
/// Non-authoritative pending target channel id from the
|
||||
/// reducer projection.
|
||||
pending_target_channel_id: Option<u64>,
|
||||
/// Whether the reducer currently allows a new join intent.
|
||||
can_join: bool,
|
||||
/// Whether the reducer currently allows leave intent.
|
||||
can_leave: bool,
|
||||
/// Join projection synchronization state.
|
||||
join_sync_state: VoiceJoinSyncState,
|
||||
/// Last stable sanitized join error code, if any.
|
||||
join_error_code: Option<VoiceJoinErrorCode>,
|
||||
},
|
||||
/// iOS audio-session interruption state (SDD-101). Emitted when
|
||||
/// interruption begins and when it ends (with the platform hint
|
||||
/// indicating whether audio should resume).
|
||||
InterruptionState {
|
||||
/// True when interruption began, false when interruption ended.
|
||||
began: bool,
|
||||
/// Platform-provided resume hint. For begin events this is false.
|
||||
should_resume: bool,
|
||||
},
|
||||
/// A text message was received from the server.
|
||||
ChatMessage {
|
||||
/// Client id of the sender.
|
||||
sender_id: u64,
|
||||
/// Nickname of the sender.
|
||||
sender_name: String,
|
||||
/// Message content.
|
||||
message: String,
|
||||
/// Target scope (server/channel/private/poke).
|
||||
target: MessageTarget,
|
||||
/// Poke notification strength, present only for poke messages.
|
||||
poke_strength: Option<PokeStrength>,
|
||||
},
|
||||
/// Human-readable TeamSpeak-style server activity.
|
||||
ServerActivity {
|
||||
/// Activity line text.
|
||||
message: String,
|
||||
},
|
||||
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||
AudioRouteChanged {
|
||||
/// New audio output route.
|
||||
route: AudioRoute,
|
||||
},
|
||||
/// A client moved to a different channel.
|
||||
ClientMoved {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Destination channel.
|
||||
new_channel_id: u64,
|
||||
},
|
||||
/// A new client connected.
|
||||
ClientJoined {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Channel the client joined.
|
||||
channel_id: u64,
|
||||
/// Display nickname.
|
||||
name: String,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A client disconnected.
|
||||
ClientLeft {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Display nickname at time of disconnect.
|
||||
name: String,
|
||||
},
|
||||
/// Client properties changed.
|
||||
ClientUpdated {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A new channel appeared.
|
||||
ChannelAdded {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Parent channel ID.
|
||||
parent: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||
/// linked-list ordering hint). Zero means first child.
|
||||
order: i64,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
/// A channel was deleted.
|
||||
ChannelRemoved {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
},
|
||||
/// Channel properties changed.
|
||||
ChannelUpdated {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of channel-join projection sync state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinSyncState {
|
||||
/// Reducer is ready to accept channel actions.
|
||||
Ready,
|
||||
/// Reducer is synchronizing against an initial snapshot.
|
||||
SynchronizingInitialSnapshot,
|
||||
/// Reducer is synchronizing after reconnect.
|
||||
SynchronizingReconnect,
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of stable channel-join error codes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinErrorCode {
|
||||
/// Duplicate same-target join intent was coalesced.
|
||||
DuplicateSameTargetCoalesced,
|
||||
/// A different target was requested while one is already pending.
|
||||
JoinAlreadyPendingDifferentTarget,
|
||||
/// Join denied by server policy/permission.
|
||||
JoinDenied,
|
||||
/// Join failed due to protocol-level error.
|
||||
JoinProtocolFailure,
|
||||
/// Join failed due to transport/network error.
|
||||
JoinNetworkFailure,
|
||||
/// Join timed out awaiting confirmation.
|
||||
JoinTimeout,
|
||||
/// Pending join was superseded by user leave.
|
||||
JoinSupersededByLeave,
|
||||
/// Stale join outcome was ignored.
|
||||
JoinStaleOutcomeIgnored,
|
||||
/// Authoritative membership reconciled to different channel.
|
||||
JoinReconciledDifferentChannel,
|
||||
/// Join command was rejected before send acceptance.
|
||||
JoinCommandRejectedBeforeSend,
|
||||
/// Join intent rejected while reducer synchronizing.
|
||||
JoinCannotStartWhileSynchronizing,
|
||||
}
|
||||
|
||||
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||
/// pure watchdog/backoff behaviour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NetworkState {
|
||||
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||
Unknown,
|
||||
/// OS reports at least one network with internet capability.
|
||||
Online,
|
||||
/// OS reports no networks available.
|
||||
Offline,
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+383
-341
@@ -52,9 +52,6 @@ use chanora_state::channel_join::{
|
||||
ConnectionEpoch, JoinFailureKind,
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod file_transfer;
|
||||
mod network_diagnostics;
|
||||
pub mod ptt;
|
||||
|
||||
pub use chanora_audio::{
|
||||
@@ -69,15 +66,49 @@ pub use chanora_diagnostics::{
|
||||
};
|
||||
pub use chanora_protocol::{
|
||||
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
||||
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
MessageTarget, 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;
|
||||
|
||||
/// Privacy-safe snapshot of the active PTT capability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PttDescriptorSnapshot {
|
||||
/// Stable capability level name.
|
||||
pub level: String,
|
||||
/// Stable backend identifier.
|
||||
pub backend_id: String,
|
||||
/// Coarse bound input class; empty when no binding is active.
|
||||
pub bound_input_class: String,
|
||||
}
|
||||
|
||||
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||
fn from(desc: PttBackendDescriptor) -> Self {
|
||||
Self {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted PTT binding state exposed to callers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedPttBinding {
|
||||
/// Stable input category string (`""`, `"keyboard"`, or
|
||||
/// `"mouse-side-button"`).
|
||||
pub input_class: String,
|
||||
/// Display-only key label; empty when no binding is active.
|
||||
pub key_label: String,
|
||||
}
|
||||
|
||||
impl PersistedPttBinding {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
input_class: String::new(),
|
||||
key_label: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
#[derive(Debug, Error)]
|
||||
@@ -94,12 +125,6 @@ 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),
|
||||
@@ -122,11 +147,291 @@ pub enum CoreError {
|
||||
Ptt(#[from] ptt::PttControllerError),
|
||||
}
|
||||
|
||||
/// High-level lifecycle event surfaced to subscribers.
|
||||
///
|
||||
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||
/// full event catalogue lands in A.4.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionEvent {
|
||||
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported in the snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; the supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Supervisor gave up after `attempt` failed retries (or the
|
||||
/// user explicitly disconnected mid-outage).
|
||||
Disconnected {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started (e.g. after a successful reconnect with
|
||||
/// reattachment).
|
||||
AudioStarted,
|
||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||
/// explicit user action).
|
||||
AudioStopped,
|
||||
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
||||
/// DEC-023..028). Published when the audio engine starts or
|
||||
/// when the active backend transitions (for example macOS
|
||||
/// permission state change). Carries only the privacy-safe
|
||||
/// descriptor — capability level, backend identifier, bound
|
||||
/// input class — per SRS-202 / DEC-027.
|
||||
PttCapability {
|
||||
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
||||
level: String,
|
||||
/// Stable backend identifier (e.g. `"focused"`).
|
||||
backend_id: String,
|
||||
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
||||
/// no binding is active.
|
||||
bound_input_class: String,
|
||||
},
|
||||
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
||||
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
||||
/// hard-mute toggles, and release-tail edits.
|
||||
VoiceState {
|
||||
/// True when the user has joined a voice channel via
|
||||
/// `voice_join` and the audio engine is running.
|
||||
in_channel: bool,
|
||||
/// Active transmit mode encoded as
|
||||
/// [`chanora_audio::TransmitMode::as_u8`].
|
||||
transmit_mode: u8,
|
||||
/// True when the hard-mute clamp is engaged.
|
||||
mute: bool,
|
||||
/// Current release-tail in milliseconds (0..=500).
|
||||
release_tail_ms: u32,
|
||||
/// Last confirmed authoritative channel id from the
|
||||
/// `channel_join` reducer projection.
|
||||
current_channel_id: Option<u64>,
|
||||
/// Non-authoritative pending target channel id from the
|
||||
/// reducer projection.
|
||||
pending_target_channel_id: Option<u64>,
|
||||
/// Whether the reducer currently allows a new join intent.
|
||||
can_join: bool,
|
||||
/// Whether the reducer currently allows leave intent.
|
||||
can_leave: bool,
|
||||
/// Join projection synchronization state.
|
||||
join_sync_state: VoiceJoinSyncState,
|
||||
/// Last stable sanitized join error code, if any.
|
||||
join_error_code: Option<VoiceJoinErrorCode>,
|
||||
},
|
||||
/// iOS audio-session interruption state (SDD-101). Emitted when
|
||||
/// interruption begins and when it ends (with the platform hint
|
||||
/// indicating whether audio should resume).
|
||||
InterruptionState {
|
||||
/// True when interruption began, false when interruption ended.
|
||||
began: bool,
|
||||
/// Platform-provided resume hint. For begin events this is false.
|
||||
should_resume: bool,
|
||||
},
|
||||
/// A text message was received from the server.
|
||||
ChatMessage {
|
||||
/// Client id of the sender.
|
||||
sender_id: u64,
|
||||
/// Nickname of the sender.
|
||||
sender_name: String,
|
||||
/// Message content.
|
||||
message: String,
|
||||
/// Target scope (server/channel/private/poke).
|
||||
target: MessageTarget,
|
||||
},
|
||||
/// Human-readable TeamSpeak-style server activity.
|
||||
ServerActivity {
|
||||
/// Activity line text.
|
||||
message: String,
|
||||
},
|
||||
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||
AudioRouteChanged {
|
||||
/// New audio output route.
|
||||
route: AudioRoute,
|
||||
},
|
||||
/// A client moved to a different channel.
|
||||
ClientMoved {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Destination channel.
|
||||
new_channel_id: u64,
|
||||
},
|
||||
/// A new client connected.
|
||||
ClientJoined {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Channel the client joined.
|
||||
channel_id: u64,
|
||||
/// Display nickname.
|
||||
name: String,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A client disconnected.
|
||||
ClientLeft {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Display nickname at time of disconnect.
|
||||
name: String,
|
||||
},
|
||||
/// Client properties changed.
|
||||
ClientUpdated {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A new channel appeared.
|
||||
ChannelAdded {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Parent channel ID.
|
||||
parent: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||
/// linked-list ordering hint). Zero means first child.
|
||||
order: i64,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
/// A channel was deleted.
|
||||
ChannelRemoved {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
},
|
||||
/// Channel properties changed.
|
||||
ChannelUpdated {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of channel-join projection sync state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinSyncState {
|
||||
/// Reducer is ready to accept channel actions.
|
||||
Ready,
|
||||
/// Reducer is synchronizing against an initial snapshot.
|
||||
SynchronizingInitialSnapshot,
|
||||
/// Reducer is synchronizing after reconnect.
|
||||
SynchronizingReconnect,
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of stable channel-join error codes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinErrorCode {
|
||||
/// Duplicate same-target join intent was coalesced.
|
||||
DuplicateSameTargetCoalesced,
|
||||
/// A different target was requested while one is already pending.
|
||||
JoinAlreadyPendingDifferentTarget,
|
||||
/// Join denied by server policy/permission.
|
||||
JoinDenied,
|
||||
/// Join failed due to protocol-level error.
|
||||
JoinProtocolFailure,
|
||||
/// Join failed due to transport/network error.
|
||||
JoinNetworkFailure,
|
||||
/// Join timed out awaiting confirmation.
|
||||
JoinTimeout,
|
||||
/// Pending join was superseded by user leave.
|
||||
JoinSupersededByLeave,
|
||||
/// Stale join outcome was ignored.
|
||||
JoinStaleOutcomeIgnored,
|
||||
/// Authoritative membership reconciled to different channel.
|
||||
JoinReconciledDifferentChannel,
|
||||
/// Join command was rejected before send acceptance.
|
||||
JoinCommandRejectedBeforeSend,
|
||||
/// Join intent rejected while reducer synchronizing.
|
||||
JoinCannotStartWhileSynchronizing,
|
||||
}
|
||||
|
||||
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||
/// pure watchdog/backoff behaviour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NetworkState {
|
||||
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||
Unknown,
|
||||
/// OS reports at least one network with internet capability.
|
||||
Online,
|
||||
/// OS reports no networks available.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// Channel capacity for the broadcast events. Generous because
|
||||
/// reconnect cycles emit several events per attempt; if subscribers
|
||||
/// fall behind we'd rather skip than block the supervisor.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NetworkDiagnostics {
|
||||
/// Total count of connects (including the initial one).
|
||||
connect_count: u64,
|
||||
/// Count of disconnects (graceful + loss).
|
||||
disconnect_count: u64,
|
||||
/// Recent loss reasons (last 8, ring buffer).
|
||||
loss_reasons: Vec<String>,
|
||||
}
|
||||
|
||||
impl NetworkDiagnostics {
|
||||
fn record_connect(&mut self) {
|
||||
self.connect_count = self.connect_count.saturating_add(1);
|
||||
}
|
||||
fn record_loss(&mut self, reason: &str) {
|
||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||
if self.loss_reasons.len() >= 8 {
|
||||
self.loss_reasons.remove(0);
|
||||
}
|
||||
self.loss_reasons.push(reason.to_string());
|
||||
}
|
||||
fn summary(&self) -> String {
|
||||
let mut s = format!(
|
||||
"connects: {}\ndisconnects: {}\n",
|
||||
self.connect_count, self.disconnect_count
|
||||
);
|
||||
if !self.loss_reasons.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"loss_reasons: [{}]\n",
|
||||
self.loss_reasons.join(", ")
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
struct SupervisorInner {
|
||||
/// Optional cached AudioEngineConfig — set when start_audio is
|
||||
/// first called, used to re-create the engine after a reconnect.
|
||||
@@ -138,6 +443,7 @@ 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
|
||||
@@ -171,20 +477,12 @@ struct ConnectedState {
|
||||
local_output_muted: bool,
|
||||
}
|
||||
|
||||
async fn take_disconnect_state<T>(inner: &Arc<Mutex<Option<T>>>) -> Option<T> {
|
||||
inner.lock().await.take()
|
||||
}
|
||||
|
||||
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
||||
password
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
fn should_dispatch_text_message(message: &str, target: &MessageTarget) -> bool {
|
||||
!message.trim().is_empty() || matches!(target, MessageTarget::Poke(_))
|
||||
}
|
||||
|
||||
/// The top-level Chanora session. Owns at most one active server
|
||||
/// connection (DEC-006).
|
||||
#[derive(Clone)]
|
||||
@@ -204,8 +502,6 @@ 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,
|
||||
@@ -261,8 +557,6 @@ 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,
|
||||
@@ -284,17 +578,6 @@ 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`]
|
||||
@@ -359,65 +642,6 @@ 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> {
|
||||
@@ -595,7 +819,6 @@ 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,
|
||||
@@ -650,9 +873,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),
|
||||
@@ -713,13 +936,9 @@ 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
|
||||
@@ -747,9 +966,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 protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(client.client_profile(client_id).await?)
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(state.protocol.client_profile(client_id).await?)
|
||||
}
|
||||
|
||||
/// True if a connection is currently active.
|
||||
@@ -763,12 +982,12 @@ impl ChanoraSession {
|
||||
message: String,
|
||||
target: MessageTarget,
|
||||
) -> Result<(), CoreError> {
|
||||
if !should_dispatch_text_message(&message, &target) {
|
||||
if message.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.send_text_message(message, target).await?;
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
state.protocol.send_text_message(message, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -827,16 +1046,11 @@ 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, 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"))?,
|
||||
)
|
||||
};
|
||||
let voice_out = state.protocol.voice_out();
|
||||
let voice_in = state
|
||||
.protocol
|
||||
.take_voice_in()
|
||||
.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(
|
||||
@@ -1067,11 +1281,10 @@ impl ChanoraSession {
|
||||
let password_to_send = requested_password
|
||||
.clone()
|
||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||
{
|
||||
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?;
|
||||
}
|
||||
state
|
||||
.protocol
|
||||
.move_to_channel(channel_id, password_to_send)
|
||||
.await?;
|
||||
if let Some(pw) = requested_password {
|
||||
state.channel_passwords.insert(channel_id, pw);
|
||||
}
|
||||
@@ -1096,11 +1309,7 @@ impl ChanoraSession {
|
||||
if let Some(muted) = output {
|
||||
state.local_output_muted = muted;
|
||||
}
|
||||
{
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.set_muted(input, output).await?;
|
||||
}
|
||||
state.protocol.set_muted(input, output).await?;
|
||||
if let Some(muted) = output {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
audio.set_output_muted(muted);
|
||||
@@ -1212,19 +1421,11 @@ impl ChanoraSession {
|
||||
/// Configure the preferred Silero ONNX VAD model path on platforms
|
||||
/// that ship the ONNX detector.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1423,12 +1624,11 @@ impl ChanoraSession {
|
||||
let password_to_send = requested_password
|
||||
.clone()
|
||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||
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 {
|
||||
if let Err(e) = state
|
||||
.protocol
|
||||
.queue_move_to_channel(channel_id, password_to_send)
|
||||
.await
|
||||
{
|
||||
// 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
|
||||
@@ -1678,7 +1878,8 @@ impl ChanoraSession {
|
||||
|
||||
/// Disconnect from the server. No-op if not connected.
|
||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||
if let Some(mut state) = take_disconnect_state(&self.inner).await {
|
||||
let mut guard = self.inner.lock().await;
|
||||
if let Some(mut state) = guard.take() {
|
||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@@ -1690,13 +1891,11 @@ impl ChanoraSession {
|
||||
audio.stop();
|
||||
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
||||
}
|
||||
if let Some(protocol) = self.take_protocol().await {
|
||||
protocol.disconnect().await;
|
||||
}
|
||||
state.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() {
|
||||
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
|
||||
let _ = handle.await;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||
reason: "user requested".to_string(),
|
||||
@@ -1735,26 +1934,9 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Number of consecutive watchdog failures before the supervisor
|
||||
/// declares the connection lost.
|
||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
||||
const SUPERVISOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration: Duration) {
|
||||
if tokio::time::timeout(timeout_duration, &mut handle)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
timeout_ms = timeout_duration.as_millis() as u64,
|
||||
"supervisor did not stop before shutdown timeout"
|
||||
);
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
struct SupervisorContext {
|
||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||
events_tx: broadcast::Sender<SessionEvent>,
|
||||
initial_cfg: ConnectConfig,
|
||||
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
||||
@@ -1786,7 +1968,6 @@ fn spawn_event_forwarders(
|
||||
sender_name: msg.sender_name,
|
||||
message: msg.message,
|
||||
target: msg.target,
|
||||
poke_strength: msg.poke_strength,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1808,77 +1989,27 @@ fn spawn_event_forwarders(
|
||||
let mut rx = delta_rx;
|
||||
while let Some(delta) = rx.recv().await {
|
||||
let event = match delta {
|
||||
ProtocolDelta::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} => SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
},
|
||||
ProtocolDelta::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
|
||||
SessionEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ClientLeft { client_id, name } => {
|
||||
SessionEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
ProtocolDelta::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
ProtocolDelta::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
ProtocolDelta::ChannelRemoved { id } => SessionEvent::ChannelRemoved { id },
|
||||
ProtocolDelta::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
ProtocolDelta::ChannelRemoved { id } => {
|
||||
SessionEvent::ChannelRemoved { id }
|
||||
}
|
||||
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
};
|
||||
let _ = ev_tx.send(event);
|
||||
}
|
||||
@@ -1889,7 +2020,6 @@ fn spawn_event_forwarders(
|
||||
async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
let SupervisorContext {
|
||||
state_arc,
|
||||
protocol,
|
||||
events_tx,
|
||||
initial_cfg,
|
||||
initial_lost_rx,
|
||||
@@ -2159,21 +2289,20 @@ 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,
|
||||
@@ -2203,9 +2332,9 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
});
|
||||
|
||||
{
|
||||
let protocol = protocol.lock().await;
|
||||
if let Some(client) = protocol.as_ref() {
|
||||
spawn_event_forwarders(client, &events_tx);
|
||||
let guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
spawn_event_forwarders(&state.protocol, &events_tx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2217,15 +2346,8 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
};
|
||||
let mut guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_mut() {
|
||||
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 voice_out = state.protocol.voice_out();
|
||||
if let Some(voice_in) = state.protocol.take_voice_in() {
|
||||
let gate = chanora_audio::AudioTransmitGate::new(
|
||||
audio_cfg.ptt_initial,
|
||||
);
|
||||
@@ -2511,54 +2633,6 @@ mod tests {
|
||||
s.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_state_take_releases_inner_lock_before_teardown() {
|
||||
let inner = Arc::new(Mutex::new(Some(())));
|
||||
|
||||
let state = super::take_disconnect_state(&inner).await;
|
||||
|
||||
assert_eq!(state, Some(()));
|
||||
assert!(inner.try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_join_returns_after_shutdown_timeout() {
|
||||
let handle = tokio::spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_shutdown_timeout_aborts_pending_task() {
|
||||
struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
|
||||
|
||||
impl Drop for DropNotice {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.0.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _notice = DropNotice(Some(dropped_tx));
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||
|
||||
tokio::time::timeout(Duration::from_millis(100), dropped_rx)
|
||||
.await
|
||||
.expect("pending supervisor task should be aborted")
|
||||
.expect("drop notice should be delivered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_detects_in_channel_move() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
@@ -2656,38 +2730,6 @@ 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();
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct NetworkDiagnostics {
|
||||
/// Total count of connects (including the initial one).
|
||||
connect_count: u64,
|
||||
/// Count of disconnects (graceful + loss).
|
||||
disconnect_count: u64,
|
||||
/// Recent loss reasons (last 8, ring buffer).
|
||||
loss_reasons: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl NetworkDiagnostics {
|
||||
pub(crate) fn record_connect(&mut self) {
|
||||
self.connect_count = self.connect_count.saturating_add(1);
|
||||
}
|
||||
|
||||
pub(crate) fn record_loss(&mut self, reason: &str) {
|
||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||
if self.loss_reasons.len() >= 8 {
|
||||
self.loss_reasons.pop_front();
|
||||
}
|
||||
self.loss_reasons.push_back(reason.to_string());
|
||||
}
|
||||
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let mut s = format!(
|
||||
"connects: {}\ndisconnects: {}\n",
|
||||
self.connect_count, self.disconnect_count
|
||||
);
|
||||
if !self.loss_reasons.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"loss_reasons: [{}]\n",
|
||||
self.loss_reasons
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_diagnostics_keeps_last_eight_loss_reasons() {
|
||||
let mut diagnostics = NetworkDiagnostics::default();
|
||||
|
||||
for i in 0..10 {
|
||||
diagnostics.record_loss(&format!("loss-{i}"));
|
||||
}
|
||||
|
||||
assert_eq!(diagnostics.disconnect_count, 10);
|
||||
assert_eq!(diagnostics.loss_reasons.len(), 8);
|
||||
assert_eq!(
|
||||
diagnostics.loss_reasons.front().map(String::as_str),
|
||||
Some("loss-2")
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics.loss_reasons.back().map(String::as_str),
|
||||
Some("loss-9")
|
||||
);
|
||||
assert!(diagnostics.summary().contains(
|
||||
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -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(any(target_os = "ios", target_os = "macos", target_os = "android")))'.dependencies]
|
||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
|
||||
/// Fixed-capacity PCM handoff from the Android render producer task to
|
||||
/// the Oboe output callback.
|
||||
pub(crate) struct AndroidRenderRing {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRing {
|
||||
pub(crate) fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
|
||||
AndroidRenderRingProducer {
|
||||
frames: Arc::clone(&self.frames),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
|
||||
AndroidRenderRingConsumer {
|
||||
frames: Arc::clone(&self.frames),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AndroidRenderRingProducer {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRingProducer {
|
||||
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
|
||||
for frame in samples.chunks_exact(2) {
|
||||
let stereo_frame = [frame[0], frame[1]];
|
||||
if self.frames.push(stereo_frame).is_err() {
|
||||
let _ = self.frames.pop();
|
||||
let _ = self.frames.push(stereo_frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AndroidRenderRingConsumer {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRingConsumer {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
|
||||
let mut chunks = out.chunks_exact_mut(2);
|
||||
for frame_out in &mut chunks {
|
||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||
frame_out.copy_from_slice(&frame);
|
||||
}
|
||||
for sample in chunks.into_remainder() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
|
||||
for frame_out in out {
|
||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||
*frame_out = (frame[0], frame[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn producer_drops_oldest_samples_when_ring_is_full() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
|
||||
producer.push_frame_lossy(&[5.0, 6.0]);
|
||||
|
||||
let mut out = [0.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
|
||||
|
||||
let mut odd_out = [9.0];
|
||||
consumer.drain_into_zero_filling(&mut odd_out);
|
||||
assert_eq!(odd_out, [0.0]);
|
||||
|
||||
producer.push_frame_lossy(&[3.0, 30.0]);
|
||||
|
||||
let mut out = [0.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_zero_fills_tail_on_underrun() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[0.25, -0.25]);
|
||||
|
||||
let mut out = [9.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ use crate::mobile_voice_backend::{
|
||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||
SharingModeChoice, VoiceAudioParams,
|
||||
};
|
||||
use chanora_protocol::OutPacket;
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::{engine::SessionAudioId, AudioError};
|
||||
@@ -85,11 +86,40 @@ use crate::processor::AudioProcessor;
|
||||
|
||||
const RENDER_REF_SLOTS: usize = 4;
|
||||
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
||||
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
|
||||
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
|
||||
|
||||
type RenderReferenceBuffer =
|
||||
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
|
||||
struct RenderReferenceBuffer {
|
||||
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
|
||||
write_idx: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl RenderReferenceBuffer {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
|
||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||
unsafe {
|
||||
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
|
||||
as *mut [f32; RENDER_REF_SAMPLES];
|
||||
(*slot).copy_from_slice(frame);
|
||||
}
|
||||
self.write_idx
|
||||
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
|
||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
|
||||
self.buf[ri]
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for RenderReferenceBuffer {}
|
||||
unsafe impl Sync for RenderReferenceBuffer {}
|
||||
|
||||
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
||||
//
|
||||
@@ -108,8 +138,9 @@ struct AndroidCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -133,7 +164,7 @@ struct AndroidCaptureState {
|
||||
|
||||
impl AndroidCaptureState {
|
||||
fn new(
|
||||
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
@@ -157,12 +188,9 @@ impl AndroidCaptureState {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"android",
|
||||
)?,
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
@@ -193,10 +221,8 @@ impl AndroidCaptureState {
|
||||
self.audio_processing_stats
|
||||
.record_callback_frames(samples.len() as u64);
|
||||
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
||||
self.resample_capture_to_48k(samples);
|
||||
let resampled = std::mem::take(&mut self.resample_scratch);
|
||||
let resampled = self.resample_capture_to_48k(samples);
|
||||
self.ingest_48k_i16(&resampled);
|
||||
self.resample_scratch = resampled;
|
||||
return;
|
||||
}
|
||||
self.ingest_48k_i16(samples);
|
||||
@@ -215,7 +241,6 @@ impl AndroidCaptureState {
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.encode_complete_20ms_frames();
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
@@ -225,10 +250,6 @@ impl AndroidCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
self.encode_complete_20ms_frames();
|
||||
}
|
||||
|
||||
fn encode_complete_20ms_frames(&mut self) {
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||
@@ -237,6 +258,7 @@ impl AndroidCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -264,18 +286,35 @@ impl AndroidCaptureState {
|
||||
}
|
||||
}
|
||||
|
||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
|
||||
let result = crate::capture_resampler::resample_capture_to_48k(
|
||||
samples,
|
||||
self.input_sample_rate_hz,
|
||||
&mut self.resample_pos,
|
||||
&mut self.resample_last,
|
||||
&mut self.resample_scratch,
|
||||
);
|
||||
if result.dropped {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
|
||||
if samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
result.output_len
|
||||
self.resample_scratch.clear();
|
||||
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||
let mut pos = self.resample_pos;
|
||||
while pos < samples.len() as f64 {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let a = if i <= 0 {
|
||||
self.resample_last as f64
|
||||
} else {
|
||||
samples[(i - 1) as usize] as f64
|
||||
};
|
||||
let b = if i < samples.len() as isize {
|
||||
samples[i as usize] as f64
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let value = (a + frac * (b - a))
|
||||
.round()
|
||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||
self.resample_scratch.push(value);
|
||||
pos += ratio;
|
||||
}
|
||||
self.resample_pos = pos - samples.len() as f64;
|
||||
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
|
||||
self.resample_scratch.clone()
|
||||
}
|
||||
|
||||
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
||||
@@ -354,9 +393,15 @@ impl AndroidCaptureState {
|
||||
self.fallback_warned_backend = None;
|
||||
match vad_backend {
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
self.silero_vad_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
let path = crate::vad::silero_model_bundle_path();
|
||||
self.silero_vad_worker =
|
||||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
||||
if self.silero_vad_worker.is_none() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.silero_vad_worker = None;
|
||||
@@ -399,10 +444,7 @@ impl AndroidCaptureState {
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -430,19 +472,21 @@ impl AndroidCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
let gain = self.mic_gain;
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum
|
||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||
} else {
|
||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InputCallback {
|
||||
state: Arc<Mutex<AndroidCaptureState>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
event_tx: BackendEventTx,
|
||||
}
|
||||
|
||||
@@ -454,13 +498,9 @@ impl AudioInputCallback for InputCallback {
|
||||
_stream: &mut dyn AudioInputStreamSafe,
|
||||
frames: &[i16],
|
||||
) -> DataCallbackResult {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
|
||||
Ok(mut s) => s.ingest_i16(frames),
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Ok(mut s) = self.state.lock() {
|
||||
s.ingest_i16(frames);
|
||||
}
|
||||
}));
|
||||
DataCallbackResult::Continue
|
||||
@@ -482,7 +522,8 @@ impl AudioInputCallback for InputCallback {
|
||||
// writes stereo f32 directly to the Oboe output buffer.
|
||||
|
||||
struct OutputCallback {
|
||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||
handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
event_tx: BackendEventTx,
|
||||
@@ -501,39 +542,47 @@ impl AudioOutputCallback for OutputCallback {
|
||||
frames: &mut [(f32, f32)],
|
||||
) -> DataCallbackResult {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
|
||||
let buf: &mut [f32] =
|
||||
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
|
||||
for s in buf.iter_mut() {
|
||||
*s = 0.0;
|
||||
}
|
||||
for cmd in self.event_consumer.drain_controls() {
|
||||
match cmd {
|
||||
AudioCommand::SetVolume(id, vol) => {
|
||||
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
||||
q.volume = vol;
|
||||
}
|
||||
}
|
||||
AudioCommand::RemoveClient(id) => {
|
||||
self.handler.get_mut_queues().remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for pkt in self.event_consumer.drain_packets(50) {
|
||||
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.handler.fill_buffer(buf);
|
||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||
if muted {
|
||||
for frame in frames.iter_mut() {
|
||||
*frame = (0.0, 0.0);
|
||||
for s in buf.iter_mut() {
|
||||
*s = 0.0;
|
||||
}
|
||||
} else if gain != 1.0 {
|
||||
for (left, right) in frames.iter_mut() {
|
||||
*left *= gain;
|
||||
*right *= gain;
|
||||
for s in buf.iter_mut() {
|
||||
*s *= gain;
|
||||
}
|
||||
}
|
||||
let mut sum_squares = 0.0_f32;
|
||||
for (left, right) in frames.iter() {
|
||||
sum_squares += left * left + right * right;
|
||||
}
|
||||
let sample_count = frames.len() * 2;
|
||||
let dbfs = if sample_count == 0 {
|
||||
-120.0
|
||||
} else {
|
||||
let rms = (sum_squares / sample_count as f32).sqrt();
|
||||
if rms <= 0.000_001 {
|
||||
-120.0
|
||||
} else {
|
||||
20.0 * rms.log10()
|
||||
}
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.update_render(dbfs, frames.len() as u32);
|
||||
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
||||
|
||||
for (left, right) in frames.iter() {
|
||||
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
|
||||
for chunk in buf.chunks_exact(2) {
|
||||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
||||
self.pending_render_ref_len += 1;
|
||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
self.render_reference.write(&self.pending_render_ref);
|
||||
@@ -565,7 +614,6 @@ impl AudioOutputCallback for OutputCallback {
|
||||
pub struct AndroidVoiceUnit {
|
||||
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||
render_producer_shutdown: Arc<AtomicBool>,
|
||||
|
||||
// Recorded achieved values (SDD-112).
|
||||
input_perf: AchievedPerformanceMode,
|
||||
@@ -587,13 +635,11 @@ pub struct AndroidVoiceUnit {
|
||||
|
||||
#[derive(Default)]
|
||||
struct HardwareEffectHandles {
|
||||
aec: Option<AndroidGlobalObject>,
|
||||
ns: Option<AndroidGlobalObject>,
|
||||
agc: Option<AndroidGlobalObject>,
|
||||
aec: Option<jni::objects::GlobalRef>,
|
||||
ns: Option<jni::objects::GlobalRef>,
|
||||
agc: Option<jni::objects::GlobalRef>,
|
||||
}
|
||||
|
||||
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
|
||||
|
||||
impl AndroidVoiceUnit {
|
||||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||||
/// once a session id is available, attach SDD-113 hardware
|
||||
@@ -660,7 +706,6 @@ impl AndroidVoiceUnit {
|
||||
|
||||
let input_cb = InputCallback {
|
||||
state: capture_state.clone(),
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let input_builder = input_builder.set_callback(input_cb);
|
||||
@@ -677,12 +722,7 @@ impl AndroidVoiceUnit {
|
||||
error = ?e,
|
||||
"android: primary input stream open failed; entering fallback ladder"
|
||||
);
|
||||
match Self::open_input_fallback(
|
||||
cfg,
|
||||
&event_tx,
|
||||
capture_state.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
) {
|
||||
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
|
||||
Ok(s) => Some(s),
|
||||
Err(fallback_err) => {
|
||||
warn!(
|
||||
@@ -749,10 +789,9 @@ impl AndroidVoiceUnit {
|
||||
|
||||
let render_ref_for_output = render_ref_buf.clone();
|
||||
let event_queue = params.event_producer.queue();
|
||||
let render_ring =
|
||||
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
|
||||
let output_cb = OutputCallback {
|
||||
pcm_consumer: render_ring.consumer(),
|
||||
handler: params.handler,
|
||||
event_consumer: AudioEventQueue::consumer(&event_queue),
|
||||
output_gain: params.output_gain.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
@@ -774,7 +813,8 @@ impl AndroidVoiceUnit {
|
||||
Self::open_output_fallback(
|
||||
cfg,
|
||||
&event_tx,
|
||||
render_ring.consumer(),
|
||||
AudioHandler::new(),
|
||||
AudioEventQueue::consumer(&event_queue),
|
||||
params.output_gain.clone(),
|
||||
params.output_muted.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
@@ -782,11 +822,6 @@ impl AndroidVoiceUnit {
|
||||
)?
|
||||
}
|
||||
};
|
||||
let render_producer_shutdown = Self::spawn_render_producer(
|
||||
params.handler,
|
||||
AudioEventQueue::consumer(&event_queue),
|
||||
render_ring.producer(),
|
||||
);
|
||||
|
||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||||
if output_frames_per_burst > 0 {
|
||||
@@ -943,7 +978,6 @@ impl AndroidVoiceUnit {
|
||||
Ok(Self {
|
||||
input: input_stream,
|
||||
output: Some(output_stream),
|
||||
render_producer_shutdown,
|
||||
input_perf,
|
||||
input_share,
|
||||
output_perf,
|
||||
@@ -961,7 +995,6 @@ impl AndroidVoiceUnit {
|
||||
cfg: &AndroidVoiceStreamConfig,
|
||||
event_tx: &BackendEventTx,
|
||||
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
||||
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
||||
// via the pure helpers in `mobile_voice_backend`. Primary
|
||||
@@ -998,7 +1031,6 @@ impl AndroidVoiceUnit {
|
||||
};
|
||||
let cb = InputCallback {
|
||||
state: capture_state.clone(),
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let builder = AudioStreamBuilder::default()
|
||||
@@ -1034,14 +1066,16 @@ impl AndroidVoiceUnit {
|
||||
fn open_output_fallback(
|
||||
cfg: &AndroidVoiceStreamConfig,
|
||||
event_tx: &BackendEventTx,
|
||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||
handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||
let cb = OutputCallback {
|
||||
pcm_consumer,
|
||||
handler,
|
||||
event_consumer,
|
||||
output_gain,
|
||||
output_muted,
|
||||
event_tx: event_tx.clone(),
|
||||
@@ -1066,50 +1100,6 @@ impl AndroidVoiceUnit {
|
||||
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
||||
}
|
||||
|
||||
fn spawn_render_producer(
|
||||
mut handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
|
||||
) -> Arc<AtomicBool> {
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let shutdown_for_task = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if shutdown_for_task.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
for cmd in event_consumer.drain_controls() {
|
||||
match cmd {
|
||||
AudioCommand::SetVolume(id, vol) => {
|
||||
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
|
||||
q.volume = vol;
|
||||
}
|
||||
}
|
||||
AudioCommand::RemoveClient(id) => {
|
||||
handler.get_mut_queues().remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for pkt in event_consumer.drain_packets(50) {
|
||||
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
}
|
||||
}
|
||||
|
||||
pull_scratch.fill(0.0);
|
||||
let _ = handler.fill_buffer(&mut pull_scratch);
|
||||
pcm_producer.push_frame_lossy(&pull_scratch);
|
||||
}
|
||||
});
|
||||
shutdown
|
||||
}
|
||||
|
||||
/// Clone of the event sender, for JNI focus / SCO listeners
|
||||
/// registered on the engine's behalf.
|
||||
pub fn event_sender(&self) -> BackendEventTx {
|
||||
@@ -1161,7 +1151,6 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
|
||||
fn close(&mut self) -> Result<(), BackendError> {
|
||||
// SDD-115 reverse order: release hardware effects FIRST,
|
||||
// then close streams.
|
||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||
release_hardware_effects(&mut self.hw_effects);
|
||||
self.stop().ok();
|
||||
// Dropping the Option drops the underlying AudioStreamAsync
|
||||
@@ -1219,7 +1208,6 @@ impl Drop for AndroidVoiceUnit {
|
||||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||||
// into the JVM (SDD-115 callback safety).
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||
release_hardware_effects(&mut self.hw_effects);
|
||||
// SDD-116: clear the diagnostics slot on Drop too.
|
||||
clear_android_audio_diagnostics();
|
||||
@@ -1299,71 +1287,62 @@ fn attach_hardware_effects_inner(
|
||||
session_id: AudioSessionId,
|
||||
effects: &crate::AudioEffects,
|
||||
) -> HardwareEffectHandles {
|
||||
with_android_env("hardware effects", |env| {
|
||||
let mut handles = HardwareEffectHandles::default();
|
||||
if effects.aec {
|
||||
handles.aec = create_effect(
|
||||
env,
|
||||
"android/media/audiofx/AcousticEchoCanceler",
|
||||
session_id,
|
||||
"AEC",
|
||||
);
|
||||
}
|
||||
if effects.noise_suppression {
|
||||
handles.ns = create_effect(
|
||||
env,
|
||||
"android/media/audiofx/NoiseSuppressor",
|
||||
session_id,
|
||||
"NS",
|
||||
);
|
||||
}
|
||||
if effects.agc {
|
||||
handles.agc = create_effect(
|
||||
env,
|
||||
"android/media/audiofx/AutomaticGainControl",
|
||||
session_id,
|
||||
"AGC",
|
||||
);
|
||||
}
|
||||
handles
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn with_android_env<R>(
|
||||
operation: &str,
|
||||
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
|
||||
) -> Option<R> {
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
operation,
|
||||
"android: ndk_context vm null; JNI call skipped"
|
||||
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
||||
);
|
||||
return None;
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
||||
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
||||
Ok(value) => Some(value),
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
||||
None
|
||||
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
};
|
||||
|
||||
let mut handles = HardwareEffectHandles::default();
|
||||
if effects.aec {
|
||||
handles.aec = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/AcousticEchoCanceler",
|
||||
session_id,
|
||||
"AEC",
|
||||
);
|
||||
}
|
||||
if effects.noise_suppression {
|
||||
handles.ns = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/NoiseSuppressor",
|
||||
session_id,
|
||||
"NS",
|
||||
);
|
||||
}
|
||||
if effects.agc {
|
||||
handles.agc = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/AutomaticGainControl",
|
||||
session_id,
|
||||
"AGC",
|
||||
);
|
||||
}
|
||||
handles
|
||||
}
|
||||
|
||||
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
||||
/// class before calling `create(int)`. Returns `false` on any JNI
|
||||
/// failure so the caller engages the software fallback.
|
||||
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
|
||||
match env.call_static_method(
|
||||
class,
|
||||
jni::jni_str!("isAvailable"),
|
||||
jni::jni_sig!("()Z"),
|
||||
&[],
|
||||
) {
|
||||
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
||||
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
||||
Ok(v) => match v.z() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -1381,14 +1360,14 @@ fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, lab
|
||||
}
|
||||
|
||||
fn create_effect(
|
||||
env: &mut jni::Env<'_>,
|
||||
env: &mut jni::JNIEnv,
|
||||
fqcn: &str,
|
||||
session_id: AudioSessionId,
|
||||
label: &str,
|
||||
) -> Option<AndroidGlobalObject> {
|
||||
) -> Option<jni::objects::GlobalRef> {
|
||||
use jni::objects::JValue;
|
||||
// Class.create(int) -> ClassInstance|null
|
||||
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
|
||||
let class = match env.find_class(fqcn) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||||
@@ -1404,18 +1383,10 @@ fn create_effect(
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};"))
|
||||
{
|
||||
Ok(sig) => sig,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let inst = match env.call_static_method(
|
||||
&class,
|
||||
jni::jni_str!("create"),
|
||||
create_sig.method_signature(),
|
||||
"create",
|
||||
&format!("(I)L{fqcn};"),
|
||||
&[JValue::Int(session_id)],
|
||||
) {
|
||||
Ok(v) => match v.l() {
|
||||
@@ -1440,8 +1411,8 @@ fn create_effect(
|
||||
// setEnabled(true) -> int (success code)
|
||||
if let Err(e) = env.call_method(
|
||||
&inst,
|
||||
jni::jni_str!("setEnabled"),
|
||||
jni::jni_sig!("(Z)I"),
|
||||
"setEnabled",
|
||||
"(Z)I",
|
||||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||||
) {
|
||||
let _ = env.exception_clear();
|
||||
@@ -1477,28 +1448,34 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
||||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||||
return;
|
||||
}
|
||||
let _ = with_android_env("release hardware effects", |env| {
|
||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||
if let Some(g) = effect {
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
jni::jni_str!("setEnabled"),
|
||||
jni::jni_sig!("(Z)I"),
|
||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||
);
|
||||
env.exception_clear();
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
jni::jni_str!("release"),
|
||||
jni::jni_sig!("()V"),
|
||||
&[],
|
||||
);
|
||||
env.exception_clear();
|
||||
drop(g);
|
||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||
}
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||
if let Some(g) = effect {
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
"setEnabled",
|
||||
"(Z)I",
|
||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||
);
|
||||
let _ = env.exception_clear();
|
||||
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
||||
let _ = env.exception_clear();
|
||||
drop(g);
|
||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Process-global BackendEvent sender for JNI callbacks --------
|
||||
@@ -1573,7 +1550,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
|
||||
fn call_voice_service_static(method: &str) -> bool {
|
||||
use jni::objects::{JObject, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.context().is_null() {
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
method,
|
||||
@@ -1581,41 +1558,54 @@ fn call_voice_service_static(method: &str) -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
with_android_env("voice foreground service", |env| {
|
||||
// SAFETY: ndk_context::context() is the application Context
|
||||
// jobject; valid global ref for process lifetime.
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
jni::strings::JNIString::new(method),
|
||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||
false
|
||||
}
|
||||
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
||||
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
||||
// the process lifetime.
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// SAFETY: ndk_context::context() is the application Context
|
||||
// jobject; valid global ref for process lifetime.
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
method,
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_app_class<'local>(
|
||||
env: &mut jni::Env<'local>,
|
||||
env: &mut jni::JNIEnv<'local>,
|
||||
context_obj: &jni::objects::JObject<'local>,
|
||||
slash_name: &str,
|
||||
) -> Option<jni::objects::JClass<'local>> {
|
||||
match env.find_class(jni::strings::JNIString::new(slash_name)) {
|
||||
match env.find_class(slash_name) {
|
||||
Ok(c) => return Some(c),
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
@@ -1626,8 +1616,8 @@ fn load_app_class<'local>(
|
||||
let loader = match env
|
||||
.call_method(
|
||||
context_obj,
|
||||
jni::jni_str!("getClassLoader"),
|
||||
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
@@ -1652,20 +1642,13 @@ fn load_app_class<'local>(
|
||||
match env
|
||||
.call_method(
|
||||
&loader,
|
||||
jni::jni_str!("loadClass"),
|
||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
{
|
||||
Ok(class_obj) => match env.cast_local::<jni::objects::JClass>(class_obj) {
|
||||
Ok(class) => Some(class),
|
||||
Err(e) => {
|
||||
env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object");
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||
@@ -1696,7 +1679,7 @@ fn load_app_class<'local>(
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
||||
'local,
|
||||
>(
|
||||
_env: jni::EnvUnowned<'local>,
|
||||
_env: jni::JNIEnv<'local>,
|
||||
_class: jni::objects::JClass<'local>,
|
||||
state: jni::sys::jint,
|
||||
) {
|
||||
@@ -1734,7 +1717,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
||||
'local,
|
||||
>(
|
||||
_env: jni::EnvUnowned<'local>,
|
||||
_env: jni::JNIEnv<'local>,
|
||||
_class: jni::objects::JClass<'local>,
|
||||
state: jni::sys::jint,
|
||||
) {
|
||||
@@ -1783,7 +1766,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
|
||||
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||
use jni::objects::{JObject, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.context().is_null() {
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
class = fqcn,
|
||||
@@ -1792,29 +1775,39 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
with_android_env("static context call", |env| {
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(env, &context_obj, fqcn) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
jni::strings::JNIString::new(method),
|
||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||
false
|
||||
}
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(&mut env, &context_obj, fqcn) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
method,
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ impl AudioRoute {
|
||||
/// iOS voice-processing mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IosVoiceProcessingMode {
|
||||
/// Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||
PlatformVoiceProcessing,
|
||||
/// Experimental raw capture-processing path.
|
||||
SonoraExperimental,
|
||||
}
|
||||
|
||||
/// Processing backend selected by policy/config.
|
||||
@@ -193,20 +195,28 @@ impl AudioProcessingConfig {
|
||||
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
||||
));
|
||||
}
|
||||
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
|
||||
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
||||
&& (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)
|
||||
{
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -252,6 +262,34 @@ 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 {
|
||||
@@ -366,8 +404,10 @@ impl Default for SharedAudioProcessingStats {
|
||||
}
|
||||
|
||||
impl SharedAudioProcessingStats {
|
||||
/// Store the raw input dBFS level for capture paths that do not
|
||||
/// update the full processing/VAD snapshot on this callback.
|
||||
/// 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.
|
||||
pub fn set_input_dbfs(&self, dbfs: f32) {
|
||||
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
pub(crate) fn append_processed_i16_bounded(
|
||||
pcm_accum: &mut Vec<i16>,
|
||||
frame: &[f32],
|
||||
gain: f32,
|
||||
) -> bool {
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
pcm_accum.push(crate::frame::f32_to_i16(src));
|
||||
}
|
||||
} else {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
|
||||
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
pcm_accum.push(src);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{append_i16_bounded, append_processed_i16_bounded};
|
||||
|
||||
#[test]
|
||||
fn append_processed_i16_bounded_does_not_grow_when_full() {
|
||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||
|
||||
assert!(dropped);
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
|
||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||
|
||||
assert!(!dropped);
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||
|
||||
assert!(dropped);
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_preserves_expected_10ms_append() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||
|
||||
assert!(!dropped);
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_preserves_full_vad_preroll_window() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
for _ in 0..16 {
|
||||
assert!(!append_i16_bounded(&mut accum, &frame));
|
||||
}
|
||||
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
assert!(append_i16_bounded(&mut accum, &frame));
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
pub(crate) struct CaptureResampleResult {
|
||||
pub(crate) output_len: usize,
|
||||
pub(crate) dropped: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn resample_capture_to_48k(
|
||||
samples: &[i16],
|
||||
input_sample_rate_hz: u32,
|
||||
resample_pos: &mut f64,
|
||||
resample_last: &mut i16,
|
||||
scratch: &mut Vec<i16>,
|
||||
) -> CaptureResampleResult {
|
||||
scratch.clear();
|
||||
if samples.is_empty() {
|
||||
return CaptureResampleResult {
|
||||
output_len: 0,
|
||||
dropped: false,
|
||||
};
|
||||
}
|
||||
|
||||
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||
let mut pos = *resample_pos;
|
||||
let mut dropped = false;
|
||||
while pos < samples.len() as f64 {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let a = if i <= 0 {
|
||||
*resample_last as f64
|
||||
} else {
|
||||
samples[(i - 1) as usize] as f64
|
||||
};
|
||||
let b = if i < samples.len() as isize {
|
||||
samples[i as usize] as f64
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let value = (a + frac * (b - a))
|
||||
.round()
|
||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||
if scratch.len() < scratch.capacity() {
|
||||
scratch.push(value);
|
||||
} else {
|
||||
dropped = true;
|
||||
}
|
||||
pos += ratio;
|
||||
}
|
||||
*resample_pos = pos - samples.len() as f64;
|
||||
*resample_last = *samples.last().unwrap_or(resample_last);
|
||||
CaptureResampleResult {
|
||||
output_len: scratch.len(),
|
||||
dropped,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod android_voice_unit_resampler_tests {
|
||||
use super::resample_capture_to_48k;
|
||||
|
||||
#[test]
|
||||
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
|
||||
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
|
||||
let mut pos = 0.0;
|
||||
let mut last = 0_i16;
|
||||
let mut scratch = Vec::with_capacity(960);
|
||||
|
||||
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||
let first_len = first.output_len;
|
||||
assert_eq!(first_len, 960);
|
||||
assert!(!first.dropped);
|
||||
assert_eq!(scratch.len(), first_len);
|
||||
let warmed_capacity = scratch.capacity();
|
||||
let warmed_ptr = scratch.as_ptr();
|
||||
|
||||
for _ in 0..8 {
|
||||
let result =
|
||||
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||
let len = result.output_len;
|
||||
assert_eq!(len, scratch.len());
|
||||
assert!(len >= 959 && len <= 960);
|
||||
assert!(!result.dropped);
|
||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
|
||||
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
|
||||
let mut pos = 0.0;
|
||||
let mut last = 0_i16;
|
||||
let mut scratch = Vec::with_capacity(960);
|
||||
let warmed_capacity = scratch.capacity();
|
||||
let warmed_ptr = scratch.as_ptr();
|
||||
|
||||
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
|
||||
|
||||
assert_eq!(result.output_len, warmed_capacity);
|
||||
assert!(result.dropped);
|
||||
assert_eq!(scratch.len(), warmed_capacity);
|
||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||
assert_eq!(pos, 0.0);
|
||||
assert_eq!(last, *samples.last().unwrap());
|
||||
}
|
||||
}
|
||||
+111
-794
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode.
|
||||
//!
|
||||
//! Provides an alternative to `ios_voice_unit.rs` for the
|
||||
//! `SonoraExperimental` processing mode. Instead of
|
||||
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
|
||||
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
|
||||
//! disabled so WebRTC APM can own the full signal path.
|
||||
//!
|
||||
//! ## Hard invariants enforced here
|
||||
//!
|
||||
//! * INV_009: Rust AEC only active when platform AEC is disabled.
|
||||
//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive.
|
||||
//! * INV_011: Software AEC backend receives both capture and render-reference.
|
||||
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
|
||||
//! before playout.
|
||||
//!
|
||||
//! ## Fallback
|
||||
//!
|
||||
//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit`
|
||||
//! (VPIO) and logs the error.
|
||||
//!
|
||||
//! ## Status
|
||||
//!
|
||||
//! Experimental / disabled by default. Only activated when the user
|
||||
//! explicitly selects `SonoraExperimental` mode via the bridge API.
|
||||
//!
|
||||
//! ## Platform
|
||||
//!
|
||||
//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK.
|
||||
//! This module is gated to `target_os = "ios"`.
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use inner::IosRawUnit;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
mod inner {
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||
use crate::processor::AudioProcessor;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Render-reference ring buffer //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/// 4-slot ring buffer shared between the render callback (writer) and
|
||||
/// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms
|
||||
/// = 40 ms of headroom.
|
||||
///
|
||||
/// If the capture callback runs before the render callback has written
|
||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
||||
/// AEC3 simply skips cancellation for that frame.
|
||||
struct RenderReferenceBuffer {
|
||||
buf: Box<[[f32; 480]; 4]>,
|
||||
write_idx: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl RenderReferenceBuffer {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
buf: Box::new([[0.0; 480]; 4]),
|
||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write one 10 ms render-reference frame. Realtime-safe.
|
||||
fn write(&self, frame: &[f32; 480]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||
// SAFETY: only one writer (render callback); torn reads
|
||||
// are bounded to one frame of AEC degradation.
|
||||
unsafe {
|
||||
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
||||
(*slot).copy_from_slice(frame);
|
||||
}
|
||||
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the most recently completed render-reference frame.
|
||||
fn read_latest(&self) -> [f32; 480] {
|
||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||
let ri = (wi + 3) % 4;
|
||||
self.buf[ri]
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: accessed from two audio callback threads; data races are
|
||||
// bounded to one frame of AEC quality degradation.
|
||||
unsafe impl Send for RenderReferenceBuffer {}
|
||||
unsafe impl Sync for RenderReferenceBuffer {}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Capture pipeline state //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
struct RawCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
silero_coreml_worker: Option<crate::vad::apple_coreml::AppleCoreMlVadWorker>,
|
||||
current_vad_backend: crate::VadBackend,
|
||||
capture_frame_seq: u64,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||
/// Processing config — retained for route-change reloads.
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: usize,
|
||||
fallback_warned_backend: Option<crate::VadBackend>,
|
||||
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
||||
}
|
||||
|
||||
impl RawCaptureState {
|
||||
fn new(
|
||||
params: &VoiceAudioParams,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
|
||||
let webrtc_apm_config = params
|
||||
.audio_processing_config
|
||||
.lock()
|
||||
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: params.voice_out_tx.clone(),
|
||||
transmit_active: params.transmit_active.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
frames_sent: params.frames_sent.clone(),
|
||||
mic_gain: params.mic_gain,
|
||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
silero_coreml_worker: None,
|
||||
current_vad_backend: crate::VadBackend::WebrtcVad,
|
||||
capture_frame_seq: 0,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||
audio_processing_config: params.audio_processing_config.clone(),
|
||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
|
||||
webrtc_apm_config,
|
||||
)?,
|
||||
audio_processing_stats: params.audio_processing_stats.clone(),
|
||||
render_reference,
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
fallback_warned_backend: None,
|
||||
wav_recorder: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
||||
if self.fallback_warned_backend != Some(failed_backend) {
|
||||
self.fallback_warned_backend = Some(failed_backend);
|
||||
if self.capture_frame_seq < 128 {
|
||||
tracing::info!(
|
||||
target: "chanora_audio",
|
||||
backend = failed_backend.as_str(),
|
||||
seq = self.capture_frame_seq,
|
||||
"VAD backend warming up; using WebRTC fallback"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
backend = failed_backend.as_str(),
|
||||
"VAD backend unavailable; using WebRTC fallback for runtime detection"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
||||
// Accumulate into 10 ms frames for VAD / Sonora processing.
|
||||
let mut offset = 0;
|
||||
while offset < samples.len() {
|
||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||
let take = remaining.min(samples.len() - offset);
|
||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||
.copy_from_slice(&samples[offset..offset + take]);
|
||||
self.pending_10ms_len += take;
|
||||
offset += take;
|
||||
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Encode complete 20 ms Opus frames.
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
||||
|
||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"ios raw: voice_out queue full; dropping frame"
|
||||
);
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(target: "chanora_audio",
|
||||
error = %e, "ios raw opus encode failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_10ms_capture_frame(
|
||||
&mut self,
|
||||
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
) {
|
||||
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
||||
*dst = crate::frame::i16_to_f32(src);
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// WAV tap: raw mic (before processing).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
|
||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
||||
let render_ref = self.render_reference.read_latest();
|
||||
self.webrtc_apm_processor.process_render(&render_ref);
|
||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
||||
|
||||
// WAV tap: processed mic (after WebRTC APM).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
||||
.unwrap_or(false);
|
||||
if !voice_activity_mode {
|
||||
self.silero_coreml_worker = None;
|
||||
self.current_vad_backend = crate::VadBackend::Disabled;
|
||||
self.fallback_warned_backend = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
|
||||
let (vad_backend, vad_hangover) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||
.unwrap_or((
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
));
|
||||
if voice_activity_mode {
|
||||
self.vad_state.configure(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
}
|
||||
|
||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker =
|
||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||
if self.silero_coreml_worker.is_none() {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
self.vad_state.reset();
|
||||
}
|
||||
|
||||
let (vad_probability, active) = if voice_activity_mode {
|
||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||
let capture_seq = self.capture_frame_seq;
|
||||
let mut used_fallback_vad = false;
|
||||
let vad = if vad_backend == crate::VadBackend::Disabled {
|
||||
crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(used_fallback_vad);
|
||||
(vad.probability, self.vad_state.update(vad.speech))
|
||||
} else {
|
||||
(0.0, false)
|
||||
};
|
||||
if let Some(sel) = &self.voice_activity_selector {
|
||||
sel.set_voice_activity_open(voice_activity_mode && active);
|
||||
}
|
||||
self.audio_processing_stats.update_capture(
|
||||
input_dbfs,
|
||||
crate::frame::dbfs(&frame),
|
||||
vad_probability,
|
||||
voice_activity_mode && active,
|
||||
self.transmit_active.load(Ordering::Relaxed),
|
||||
);
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let gain = self.mic_gain;
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum
|
||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||
} else {
|
||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// IosRawUnit //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/// Raw iOS RemoteIO audio unit for the Sonora experimental path.
|
||||
pub struct IosRawUnit {
|
||||
unit: AudioUnit,
|
||||
}
|
||||
|
||||
impl IosRawUnit {
|
||||
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
|
||||
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
|
||||
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
|
||||
{
|
||||
let cfg = params.audio_processing_config.lock().unwrap();
|
||||
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"IosRawUnit requires raw WebRTC APM mode".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO)
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?;
|
||||
|
||||
// Enable input on bus 1.
|
||||
const ENABLE_IO: u32 = 2003;
|
||||
let enable: u32 = 1;
|
||||
unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable))
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?;
|
||||
|
||||
// 48 kHz Int16 mono on both buses.
|
||||
let fmt = StreamFormat {
|
||||
sample_rate: SAMPLE_RATE_HZ,
|
||||
sample_format: SampleFormat::I16,
|
||||
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
|
||||
channels: 1,
|
||||
};
|
||||
unit.set_stream_format(fmt, Scope::Input, Element::Output)
|
||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?;
|
||||
unit.set_stream_format(fmt, Scope::Output, Element::Input)
|
||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?;
|
||||
|
||||
// Shared render-reference buffer (INV_011 / INV_012).
|
||||
let render_ref_buf = RenderReferenceBuffer::new();
|
||||
let render_ref_for_capture = render_ref_buf.clone();
|
||||
|
||||
let mut capture_state = RawCaptureState::new(¶ms, render_ref_for_capture)?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
capture_state.ingest_i16(args.data.buffer);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||
|
||||
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
||||
let handler = params.handler.clone();
|
||||
let output_gain = params.output_gain.clone();
|
||||
let output_muted = params.output_muted.clone();
|
||||
let stats_render = params.audio_processing_stats.clone();
|
||||
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out = args.data.buffer;
|
||||
let n = out.len();
|
||||
let stereo_n = n * 2;
|
||||
if scratch.len() < stereo_n {
|
||||
scratch.resize(stereo_n, 0.0);
|
||||
}
|
||||
scratch[..stereo_n].fill(0.0);
|
||||
|
||||
match handler.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch[..stereo_n]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
stats_render.increment_callback_xrun();
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
warn!(target: "chanora_audio",
|
||||
"AudioHandler poisoned (raw render): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// INV_012: copy render reference BEFORE playout.
|
||||
let mono_n = n.min(480);
|
||||
let mut ref_frame = [0.0_f32; 480];
|
||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||
&scratch[..stereo_n],
|
||||
&mut ref_frame[..mono_n],
|
||||
);
|
||||
render_ref_buf.write(&ref_frame);
|
||||
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
&scratch[..stereo_n],
|
||||
out,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?;
|
||||
|
||||
unit.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?;
|
||||
unit.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?;
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
sample_rate_hz = SAMPLE_RATE_HZ,
|
||||
"ios RemoteIO (Sonora experimental) started"
|
||||
);
|
||||
Ok(Self { unit })
|
||||
}
|
||||
|
||||
/// Restart the unit after a route change (stop → uninit → init → start).
|
||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.stop()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?;
|
||||
self.unit
|
||||
.uninitialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?;
|
||||
self.unit
|
||||
.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?;
|
||||
self.unit
|
||||
.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?;
|
||||
info!(target: "chanora_audio", "ios RemoteIO restarted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause the unit during an AVAudioSession interruption.
|
||||
pub fn pause(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.stop()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio pause: {e}")))
|
||||
}
|
||||
|
||||
/// Resume the unit after an interruption ends.
|
||||
pub fn resume(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio resume: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IosRawUnit {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.unit.stop() {
|
||||
warn!(target: "chanora_audio", error = %e,
|
||||
"ios RemoteIO stop on drop failed");
|
||||
} else {
|
||||
info!(target: "chanora_audio", "ios RemoteIO stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
//! * AVAudioSession category / mode configuration — Swift owns the
|
||||
//! session (it must be set up before Flutter loads).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
@@ -75,10 +75,12 @@ use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
/// Sample rate every layer above us assumes. Matches the Opus
|
||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||
@@ -103,15 +105,6 @@ const INPUT_BUS: Element = Element::Input;
|
||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||
const PRE_ROLL_FRAMES: usize = 16;
|
||||
|
||||
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
|
||||
/// growing inside the input callback.
|
||||
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
|
||||
|
||||
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
|
||||
/// to this capacity and the remaining output is silence.
|
||||
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
|
||||
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
|
||||
|
||||
/// Capture pipeline state owned by the VPIO input callback. The
|
||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||
@@ -139,9 +132,10 @@ struct IosCaptureState {
|
||||
/// jitter without reallocating.
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -160,6 +154,7 @@ struct IosCaptureState {
|
||||
pre_roll_count: usize,
|
||||
pre_roll_flushed: bool,
|
||||
capture_frame_seq: u64,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
}
|
||||
|
||||
impl IosCaptureState {
|
||||
@@ -167,20 +162,20 @@ impl IosCaptureState {
|
||||
/// Encoder configuration is the same as cpal-side
|
||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||
/// neutral.
|
||||
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
||||
fn new(
|
||||
params: &VoiceAudioParams,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
||||
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-vpio",
|
||||
)?,
|
||||
voice_out_tx: params.voice_out_tx.clone(),
|
||||
transmit_active: params.transmit_active.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
frames_sent: params.frames_sent.clone(),
|
||||
mic_gain: params.mic_gain,
|
||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
@@ -198,6 +193,7 @@ impl IosCaptureState {
|
||||
pre_roll_count: 0,
|
||||
pre_roll_flushed: false,
|
||||
capture_frame_seq: 0,
|
||||
wav_recorder,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,6 +267,7 @@ impl IosCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -301,9 +298,25 @@ impl IosCaptureState {
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// WAV tap: raw mic (before processing, DIAG_002).
|
||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||
// last-known values if the lock is contended — safe to miss one frame).
|
||||
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
||||
let (
|
||||
run_ns,
|
||||
run_agc,
|
||||
run_hpf,
|
||||
vad_backend,
|
||||
vad_hangover,
|
||||
debug_wav_dump_enabled,
|
||||
route,
|
||||
processing_backend,
|
||||
) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| {
|
||||
@@ -319,6 +332,8 @@ impl IosCaptureState {
|
||||
cfg.vad_backend,
|
||||
cfg.vad_hangover_ms,
|
||||
cfg.debug_wav_dump_enabled,
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
@@ -328,13 +343,10 @@ impl IosCaptureState {
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
false,
|
||||
crate::AudioRoute::Unknown,
|
||||
crate::AudioBackend::PlatformVoiceProcessing,
|
||||
));
|
||||
|
||||
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
|
||||
// path allocates per frame. Debug WAV capture is intentionally disabled
|
||||
// here until the recorder can hand off preallocated frames.
|
||||
let _ = debug_wav_dump_enabled;
|
||||
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
@@ -347,13 +359,32 @@ impl IosCaptureState {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
|
||||
// Switch VAD backend only while VoiceActivity mode is active.
|
||||
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
||||
if debug_wav_dump_enabled {
|
||||
if recorder_guard.is_none() {
|
||||
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
||||
route,
|
||||
processing_backend,
|
||||
));
|
||||
}
|
||||
} else if let Some(recorder) = recorder_guard.take() {
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
self.silero_coreml_worker =
|
||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||
if self.silero_coreml_worker.is_none() {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
@@ -430,10 +461,7 @@ impl IosCaptureState {
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -456,6 +484,13 @@ impl IosCaptureState {
|
||||
transmit_active,
|
||||
);
|
||||
|
||||
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to i16 for accumulation.
|
||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||
@@ -493,13 +528,7 @@ impl IosCaptureState {
|
||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||
for i in 0..pre_roll_to_emit {
|
||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||
if crate::capture_accumulator::append_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&self.pre_roll_buf[idx],
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
break;
|
||||
}
|
||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||
}
|
||||
} else if !transmit_active {
|
||||
// Gate closed — reset the flush flag so pre-roll fires again
|
||||
@@ -511,9 +540,7 @@ impl IosCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,7 +694,9 @@ impl IosVoiceUnit {
|
||||
Element::Output,
|
||||
Some(&ducking_config),
|
||||
) {
|
||||
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
|
||||
tracing::debug!(
|
||||
"vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}"
|
||||
);
|
||||
}
|
||||
|
||||
// Note: we keep VPIO's voice processing chain ENABLED
|
||||
@@ -719,7 +748,18 @@ impl IosVoiceUnit {
|
||||
// scratch are owned by the closure — no Mutex needed
|
||||
// because the input callback is the sole writer/reader on
|
||||
// the audio thread.
|
||||
let mut capture_state = IosCaptureState::new(¶ms)?;
|
||||
let wav_recorder = Arc::new(Mutex::new({
|
||||
let cfg = params.audio_processing_config.lock().unwrap().clone();
|
||||
if cfg.debug_wav_dump_enabled {
|
||||
Some(crate::debug_wav::WavDebugRecorder::start(
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
let mut capture_state = IosCaptureState::new(¶ms, wav_recorder.clone())?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
// VPIO with our pinned stream format delivers
|
||||
@@ -839,8 +879,11 @@ impl IosVoiceUnit {
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(
|
||||
tokio::time::MissedTickBehavior::Delay,
|
||||
);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
||||
@@ -866,16 +909,16 @@ impl IosVoiceUnit {
|
||||
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data, num_frames, ..
|
||||
data,
|
||||
num_frames,
|
||||
..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
let needed = num_frames * out_channels;
|
||||
|
||||
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = 0;
|
||||
}
|
||||
for sample in &mut out[..needed] { *sample = 0; }
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -896,8 +939,10 @@ impl IosVoiceUnit {
|
||||
let mono = (l_lim + r_lim) * 0.5;
|
||||
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
} else {
|
||||
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
out[base] =
|
||||
(l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
out[base + 1] =
|
||||
(r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
}
|
||||
written_frames += 1;
|
||||
}
|
||||
@@ -906,18 +951,17 @@ impl IosVoiceUnit {
|
||||
let remaining = num_frames - written_frames;
|
||||
for f in 0..remaining {
|
||||
let base = (written_frames + f) * out_channels;
|
||||
for c in 0..out_channels {
|
||||
out[base + c] = 0;
|
||||
}
|
||||
for c in 0..out_channels { out[base + c] = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let gain = f32::from_bits(
|
||||
output_gain_for_render.load(Ordering::Relaxed),
|
||||
);
|
||||
let muted =
|
||||
output_muted_for_render.load(Ordering::Relaxed);
|
||||
if muted {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = 0;
|
||||
}
|
||||
for sample in &mut out[..needed] { *sample = 0; }
|
||||
} else if gain != 1.0 {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = (((*sample as f32) * gain)
|
||||
@@ -928,7 +972,9 @@ impl IosVoiceUnit {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
.map_err(|e| AudioError::Backend(format!(
|
||||
"audio unit set render callback: {e}"
|
||||
)))?;
|
||||
}
|
||||
|
||||
// iOS path: direct fill_buffer in callback. iOS VPIO
|
||||
@@ -938,112 +984,127 @@ impl IosVoiceUnit {
|
||||
// producer-task path above.
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
|
||||
let handler_for_render = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||
// Level meter decimation: the render callback fires ~93
|
||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||
let mut render_level_decimation: u32 = 0;
|
||||
// Debug WAV render-reference capture is intentionally unavailable
|
||||
// on iOS VPIO callbacks until WavDebugRecorder supports a
|
||||
// preallocated handoff; its current push path allocates per frame.
|
||||
// Diagnostic counters sampled every 100 callbacks.
|
||||
let mut cb_count: u64 = 0;
|
||||
let mut last_num_frames: usize = 0;
|
||||
let mut num_frames_changes: u64 = 0;
|
||||
let mut callbacks_with_audio: u64 = 0;
|
||||
let mut callbacks_with_silence: u64 = 0;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data, num_frames, ..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
||||
if process_frames < num_frames {
|
||||
let mut scratch_stereo: Vec<f32> = vec![0.0; 4096 * 2];
|
||||
let handler_for_render = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||
// Level meter decimation: the render callback fires ~93
|
||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||
let mut render_level_decimation: u32 = 0;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data,
|
||||
num_frames,
|
||||
..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
||||
let needed = num_frames * 2;
|
||||
if scratch_stereo.len() < needed {
|
||||
scratch_stereo.resize(needed, 0.0);
|
||||
}
|
||||
// Zero the live slice. AudioHandler::fill_buffer is
|
||||
// additive (does NOT clear); residual values from
|
||||
// earlier callbacks (when scratch was bigger) would
|
||||
// leak through otherwise.
|
||||
scratch_stereo[..needed].fill(0.0);
|
||||
match handler_for_render.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
audio_processing_stats_for_render.increment_callback_xrun();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
||||
let needed = process_frames * 2;
|
||||
// Zero the live slice. AudioHandler::fill_buffer is
|
||||
// additive (does NOT clear); residual values from
|
||||
// earlier callbacks (when scratch was bigger) would
|
||||
// leak through otherwise.
|
||||
scratch_stereo[..needed].fill(0.0);
|
||||
match handler_for_render.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
audio_processing_stats_for_render.increment_callback_xrun();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
// Never panic on the realtime IO thread.
|
||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
// Never panic on the realtime IO thread.
|
||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||
}
|
||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||
// without this the downmix helper would hard-clip to i16::MAX.
|
||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
&scratch_stereo[..needed],
|
||||
out,
|
||||
out_channels,
|
||||
gain,
|
||||
muted,
|
||||
}
|
||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||
// without this the downmix helper would hard-clip to i16::MAX.
|
||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
&scratch_stereo[..needed],
|
||||
out,
|
||||
out_channels,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
||||
if render_level_decimation % 3 == 0 {
|
||||
audio_processing_stats_for_render.update_render(
|
||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||
num_frames as u32,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
audio_processing_stats_for_render
|
||||
.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
||||
if render_level_decimation % 3 == 0 {
|
||||
audio_processing_stats_for_render.update_render(
|
||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||
num_frames as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||
// design), not a starved render path. Gate on !muted to avoid
|
||||
// counting deliberate silence as an output underrun.
|
||||
if !muted {
|
||||
audio_processing_stats_for_render.increment_output_underrun();
|
||||
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
if !render_recorder_active {
|
||||
render_ref_len = 0;
|
||||
render_ref_accum.fill(0.0);
|
||||
render_recorder_active = true;
|
||||
}
|
||||
let mut idx = 0;
|
||||
while idx + 1 < needed {
|
||||
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
||||
render_ref_accum[render_ref_len] = mono;
|
||||
render_ref_len += 1;
|
||||
idx += 2;
|
||||
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
rec.push_render_reference(&render_ref_accum);
|
||||
render_ref_len = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||
// design), not a starved render path. Gate on !muted to avoid
|
||||
// counting deliberate silence as an output underrun.
|
||||
if !muted {
|
||||
audio_processing_stats_for_render.increment_output_underrun();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic sampling.
|
||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||
}
|
||||
last_num_frames = num_frames;
|
||||
cb_count = cb_count.wrapping_add(1);
|
||||
if cb_count.is_multiple_of(100) {
|
||||
debug!(
|
||||
target: "chanora_audio",
|
||||
cb = cb_count,
|
||||
num_frames,
|
||||
frames_changes = num_frames_changes,
|
||||
callbacks_with_audio,
|
||||
callbacks_with_silence,
|
||||
peak_out_i16 = mix_stats.peak_i16,
|
||||
gain,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
// Diagnostic sampling.
|
||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||
}
|
||||
last_num_frames = num_frames;
|
||||
cb_count = cb_count.wrapping_add(1);
|
||||
if cb_count.is_multiple_of(100) {
|
||||
debug!(
|
||||
target: "chanora_audio",
|
||||
cb = cb_count,
|
||||
num_frames,
|
||||
frames_changes = num_frames_changes,
|
||||
callbacks_with_audio,
|
||||
callbacks_with_silence,
|
||||
peak_out_i16 = mix_stats.peak_i16,
|
||||
gain,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
} // end #[cfg(target_os = "ios")] block
|
||||
|
||||
// Finalise the unit — allocates internal buffers per the
|
||||
|
||||
@@ -28,17 +28,10 @@
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod android_render_ring;
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod audio_event_queue;
|
||||
pub mod audio_processing;
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod capture_accumulator;
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod capture_resampler;
|
||||
pub mod debug_wav;
|
||||
mod engine;
|
||||
pub mod frame;
|
||||
@@ -49,11 +42,6 @@ pub mod processor;
|
||||
pub mod ptt;
|
||||
pub mod ptt_backends;
|
||||
pub mod release_tail;
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "android", target_os = "ios", test)),
|
||||
allow(dead_code)
|
||||
)]
|
||||
pub(crate) mod render_reference;
|
||||
pub mod route_policy;
|
||||
pub mod transmit_mode;
|
||||
pub mod transmit_selector;
|
||||
@@ -65,7 +53,10 @@ pub(crate) mod voice_render;
|
||||
mod sdl_output;
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
mod ios_voice_unit;
|
||||
mod ios_voice_unit;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_raw_unit;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_voice_unit;
|
||||
|
||||
@@ -3,18 +3,15 @@ use audiopus::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
};
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
|
||||
|
||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||
const VOIP_COMPLEXITY: u8 = 10;
|
||||
@@ -57,129 +54,10 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) struct EncodedVoiceFrame {
|
||||
data: [u8; MAX_OPUS_FRAME],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct EncodedVoiceFrameSender {
|
||||
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
|
||||
open: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
enum EncodedVoiceFrameSendError {
|
||||
Full,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl EncodedVoiceFrameSender {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
queue: Arc::new(ArrayQueue::new(capacity)),
|
||||
open: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
|
||||
Arc::clone(&self.queue)
|
||||
}
|
||||
|
||||
fn worker_open_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.open)
|
||||
}
|
||||
|
||||
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
|
||||
if !self.open.load(Ordering::Relaxed) {
|
||||
return Err(EncodedVoiceFrameSendError::Closed);
|
||||
}
|
||||
self.queue
|
||||
.push(frame)
|
||||
.map_err(|_| EncodedVoiceFrameSendError::Full)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_out_packet_worker(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
context: &'static str,
|
||||
) -> Result<EncodedVoiceFrameSender, AudioError> {
|
||||
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
|
||||
std::thread::Builder::new()
|
||||
.name(name)
|
||||
.spawn(worker)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn start_out_packet_worker_with_spawner<S>(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
context: &'static str,
|
||||
spawn: S,
|
||||
) -> Result<EncodedVoiceFrameSender, AudioError>
|
||||
where
|
||||
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
|
||||
{
|
||||
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
|
||||
let rx = tx.worker_queue();
|
||||
let worker_open = tx.worker_open_flag();
|
||||
spawn(
|
||||
format!("chanora-{context}-voice-packets"),
|
||||
Box::new(move || {
|
||||
loop {
|
||||
let Some(frame) = rx.pop() else {
|
||||
if Arc::strong_count(&rx) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
continue;
|
||||
};
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: frame.as_slice(),
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
|
||||
worker_open.store(false, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tx.open.store(false, Ordering::Relaxed);
|
||||
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
|
||||
})?;
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
impl EncodedVoiceFrame {
|
||||
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
|
||||
if len > opus_out.len() || len > MAX_OPUS_FRAME {
|
||||
return None;
|
||||
}
|
||||
let mut data = [0u8; MAX_OPUS_FRAME];
|
||||
data[..len].copy_from_slice(&opus_out[..len]);
|
||||
Some(Self { data, len })
|
||||
}
|
||||
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
&self.data[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||
pub(crate) fn send_voip_frame<F, G>(
|
||||
voice_out_tx: &EncodedVoiceFrameSender,
|
||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||
frames_sent: &AtomicU32,
|
||||
opus_out: &[u8],
|
||||
len: usize,
|
||||
on_full: F,
|
||||
@@ -188,77 +66,16 @@ pub(crate) fn send_voip_frame<F, G>(
|
||||
F: FnOnce(),
|
||||
G: FnOnce(),
|
||||
{
|
||||
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
|
||||
on_full();
|
||||
return;
|
||||
};
|
||||
match voice_out_tx.push(frame) {
|
||||
Ok(()) => {}
|
||||
Err(EncodedVoiceFrameSendError::Full) => on_full(),
|
||||
Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_copies_into_fixed_storage() {
|
||||
let source = [7u8; MAX_OPUS_FRAME];
|
||||
|
||||
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
|
||||
|
||||
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
|
||||
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
|
||||
let source = [0u8; MAX_OPUS_FRAME];
|
||||
|
||||
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_sender_reports_full_without_blocking() {
|
||||
let sender = EncodedVoiceFrameSender::new(1);
|
||||
let source = [3u8; MAX_OPUS_FRAME];
|
||||
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||
|
||||
assert!(sender.push(first).is_ok());
|
||||
assert!(sender.push(second).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
|
||||
let sender = EncodedVoiceFrameSender::new(1);
|
||||
sender.open.store(false, Ordering::Relaxed);
|
||||
let source = [3u8; MAX_OPUS_FRAME];
|
||||
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
sender.push(frame),
|
||||
Err(EncodedVoiceFrameSendError::Closed)
|
||||
));
|
||||
assert_eq!(sender.queue.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_sender_reports_spawn_failure() {
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let result = start_out_packet_worker_with_spawner(
|
||||
voice_out_tx,
|
||||
frames_sent,
|
||||
"test",
|
||||
|_name, _worker| Err(std::io::Error::other("spawn failed")),
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
|
||||
);
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: &opus_out[..len],
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
use core::fmt;
|
||||
|
||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||
use thiserror::Error;
|
||||
|
||||
mod focused;
|
||||
|
||||
@@ -109,51 +108,35 @@ impl fmt::Display for PttInputClass {
|
||||
}
|
||||
|
||||
/// Errors raised by a desktop PTT backend.
|
||||
#[derive(Debug, Error)]
|
||||
#[derive(Debug)]
|
||||
pub enum PttBackendError {
|
||||
/// The OS rejected the backend initialisation (e.g. Raw Input
|
||||
/// registration failed, event tap creation failed).
|
||||
#[error("init failed: {0}")]
|
||||
Init(String),
|
||||
/// The user-granted permission required for global capture is
|
||||
/// not granted (typically macOS Input Monitoring / Accessibility).
|
||||
#[error("permission denied")]
|
||||
PermissionDenied,
|
||||
/// The display server or compositor does not expose the
|
||||
/// expected interface (typically a non-tested Linux compositor).
|
||||
#[error("unsupported environment")]
|
||||
UnsupportedEnvironment,
|
||||
/// Caller submitted a binding whose `platform_key` cannot be
|
||||
/// parsed in the active OS.
|
||||
#[error("invalid binding: {0}")]
|
||||
InvalidBinding(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ptt_backend_error_display_strings_stay_stable() {
|
||||
assert_eq!(
|
||||
PttBackendError::Init("rawinput".into()).to_string(),
|
||||
"init failed: rawinput"
|
||||
);
|
||||
assert_eq!(
|
||||
PttBackendError::PermissionDenied.to_string(),
|
||||
"permission denied"
|
||||
);
|
||||
assert_eq!(
|
||||
PttBackendError::UnsupportedEnvironment.to_string(),
|
||||
"unsupported environment"
|
||||
);
|
||||
assert_eq!(
|
||||
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
||||
"invalid binding: bad key"
|
||||
);
|
||||
impl fmt::Display for PttBackendError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Init(s) => write!(f, "init failed: {s}"),
|
||||
Self::PermissionDenied => f.write_str("permission denied"),
|
||||
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
||||
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PttBackendError {}
|
||||
|
||||
/// Cross-platform desktop PTT backend (SDD-081).
|
||||
///
|
||||
/// All implementations call exactly the audio transmit gate's
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::thread;
|
||||
|
||||
use tracing::{info, warn};
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Foundation::{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, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||
HC_ACTION, HHOOK, 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 = match unsafe {
|
||||
let hwnd = unsafe {
|
||||
CreateWindowExW(
|
||||
WINDOW_EX_STYLE(0),
|
||||
class_name,
|
||||
@@ -433,23 +433,13 @@ unsafe fn run_raw_input_loop(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(HWND(HWND_MESSAGE_PTR as *mut core::ffi::c_void)),
|
||||
HWND(HWND_MESSAGE_PTR),
|
||||
None,
|
||||
Some(HINSTANCE(h_instance.0)),
|
||||
h_instance,
|
||||
None,
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||
);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if hwnd.0.is_null() {
|
||||
if hwnd.0 == 0 {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||
@@ -527,13 +517,13 @@ unsafe fn run_raw_input_loop(
|
||||
usUsagePage: 0x01,
|
||||
usUsage: 0x06,
|
||||
dwFlags: RIDEV_REMOVE,
|
||||
hwndTarget: HWND(std::ptr::null_mut()),
|
||||
hwndTarget: HWND(0),
|
||||
},
|
||||
RAWINPUTDEVICE {
|
||||
usUsagePage: 0x01,
|
||||
usUsage: 0x02,
|
||||
dwFlags: RIDEV_REMOVE,
|
||||
hwndTarget: HWND(std::ptr::null_mut()),
|
||||
hwndTarget: HWND(0),
|
||||
},
|
||||
];
|
||||
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
|
||||
@@ -554,7 +544,7 @@ unsafe extern "system" fn raw_input_wnd_proc(
|
||||
}
|
||||
|
||||
unsafe fn handle_wm_input(lparam: LPARAM) {
|
||||
let h_raw = HRAWINPUT(lparam.0 as *mut core::ffi::c_void);
|
||||
let h_raw = HRAWINPUT(lparam.0);
|
||||
let mut size: u32 = 0;
|
||||
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
|
||||
// First call: query buffer size.
|
||||
@@ -851,33 +841,31 @@ 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, Some(HINSTANCE(h_instance.0)), 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
||||
);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mouse_hook =
|
||||
match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
||||
);
|
||||
let _ = UnhookWindowsHookEx(kbd_hook);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let kbd_hook = match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, h_instance, 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
||||
);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, h_instance, 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
||||
);
|
||||
let _ = UnhookWindowsHookEx(kbd_hook);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
@@ -920,7 +908,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
|
||||
}
|
||||
});
|
||||
}
|
||||
CallNextHookEx(None, code, wparam, lparam)
|
||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
||||
@@ -955,7 +943,7 @@ unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPA
|
||||
}
|
||||
});
|
||||
}
|
||||
CallNextHookEx(None, code, wparam, lparam)
|
||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
use std::array;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
const NO_LATEST_SLOT: usize = usize::MAX;
|
||||
|
||||
struct Slot<const SAMPLES: usize> {
|
||||
version: AtomicUsize,
|
||||
samples: [AtomicU32; SAMPLES],
|
||||
#[cfg(test)]
|
||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize> Slot<SAMPLES> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version: AtomicUsize::new(0),
|
||||
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
|
||||
#[cfg(test)]
|
||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
|
||||
pending: [f32; SAMPLES],
|
||||
pending_len: usize,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
|
||||
pub(crate) fn new() -> Self {
|
||||
assert!(
|
||||
SAMPLES > 0,
|
||||
"RenderReferenceFrameAccumulator requires at least one sample"
|
||||
);
|
||||
Self {
|
||||
pending: [0.0; SAMPLES],
|
||||
pending_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push_mono_samples(
|
||||
&mut self,
|
||||
mut samples: &[f32],
|
||||
mut publish: impl FnMut(&[f32; SAMPLES]),
|
||||
) {
|
||||
while !samples.is_empty() {
|
||||
let needed = SAMPLES - self.pending_len;
|
||||
let take = needed.min(samples.len());
|
||||
self.pending[self.pending_len..self.pending_len + take]
|
||||
.copy_from_slice(&samples[..take]);
|
||||
self.pending_len += take;
|
||||
samples = &samples[take..];
|
||||
|
||||
if self.pending_len == SAMPLES {
|
||||
publish(&self.pending);
|
||||
self.pending_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pending_len(&self) -> usize {
|
||||
self.pending_len
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
|
||||
slots: Box<[Slot<SAMPLES>; SLOTS]>,
|
||||
write_idx: AtomicUsize,
|
||||
latest_slot: AtomicUsize,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
assert!(
|
||||
SLOTS > 0,
|
||||
"RenderReferenceBuffer requires at least one slot"
|
||||
);
|
||||
Arc::new(Self {
|
||||
slots: Box::new(array::from_fn(|_| Slot::new())),
|
||||
write_idx: AtomicUsize::new(0),
|
||||
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
|
||||
let slot = &self.slots[idx];
|
||||
// The acquire half keeps payload stores after the odd in-progress marker.
|
||||
let version = slot.version.fetch_add(1, Ordering::AcqRel);
|
||||
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
|
||||
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
|
||||
sample.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
slot.version
|
||||
.store(version.wrapping_add(2) & !1, Ordering::Release);
|
||||
self.latest_slot.store(idx, Ordering::Release);
|
||||
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
|
||||
let mut out = [0.0_f32; SAMPLES];
|
||||
self.read_latest_into(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
if idx == NO_LATEST_SLOT {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
let slot = &self.slots[idx];
|
||||
let before = slot.version.load(Ordering::Acquire);
|
||||
if before & 1 == 1 {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
|
||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
|
||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||
if idx == 0
|
||||
&& slot
|
||||
.bump_after_first_sample_read
|
||||
.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
slot.version.fetch_add(2, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
let after = slot.version.load(Ordering::Acquire);
|
||||
if before != after || after & 1 == 1 {
|
||||
out.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mark_latest_slot_in_progress_for_test(&self) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
assert_ne!(idx, NO_LATEST_SLOT);
|
||||
self.slots[idx].version.fetch_or(1, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
assert_ne!(idx, NO_LATEST_SLOT);
|
||||
self.slots[idx]
|
||||
.bump_after_first_sample_read
|
||||
.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
|
||||
|
||||
#[test]
|
||||
fn render_reference_reads_zero_before_first_publish() {
|
||||
let buffer = RenderReferenceBuffer::<4, 2>::new();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_gets_latest_complete_frame() {
|
||||
let buffer = RenderReferenceBuffer::<4, 3>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
|
||||
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
|
||||
|
||||
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_writes_wrap_without_returning_stale_frame() {
|
||||
let buffer = RenderReferenceBuffer::<2, 2>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
buffer.write(&[3.0, 4.0]);
|
||||
buffer.write(&[5.0, 6.0]);
|
||||
|
||||
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_accumulator_publishes_only_complete_frames() {
|
||||
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
|
||||
let mut frames = Vec::new();
|
||||
|
||||
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
|
||||
|
||||
assert!(frames.is_empty());
|
||||
assert_eq!(accum.pending_len(), 2);
|
||||
|
||||
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
|
||||
|
||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
|
||||
assert_eq!(accum.pending_len(), 3);
|
||||
|
||||
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
|
||||
|
||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
|
||||
assert_eq!(accum.pending_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_rejects_in_progress_slot() {
|
||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
buffer.mark_latest_slot_in_progress_for_test();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
|
||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
|
||||
buffer.bump_latest_slot_version_after_first_sample_for_test();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
|
||||
fn render_reference_rejects_zero_slots() {
|
||||
let _ = RenderReferenceBuffer::<2, 0>::new();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub mod apple_coreml;
|
||||
pub mod resampler;
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub mod silero_onnx;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -18,7 +18,7 @@ use crate::frame::{f32_to_i16, i16_to_f32};
|
||||
use crate::AudioError;
|
||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub use silero_onnx::SileroOnnxVad;
|
||||
|
||||
/// Voice activity detector output for one 10 ms frame.
|
||||
@@ -111,9 +111,6 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static SILERO_MODEL_PATH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
||||
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
@@ -148,19 +145,6 @@ 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
|
||||
@@ -250,20 +234,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_rejects_missing_file() {
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
clear_silero_model_path_for_test();
|
||||
|
||||
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||
|
||||
assert!(result.is_err());
|
||||
clear_silero_model_path_for_test();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_updates_override_and_epoch() {
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
clear_silero_model_path_for_test();
|
||||
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
||||
std::fs::write(&path, b"test").unwrap();
|
||||
@@ -273,7 +250,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,31 +361,6 @@ 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 {
|
||||
@@ -418,15 +393,8 @@ 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();
|
||||
// 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();
|
||||
}
|
||||
let _ = self.handle.take();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/// Diagnostics returned by render downmix helpers.
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RenderDownmixStats {
|
||||
/// Peak absolute sample magnitude after i16 conversion.
|
||||
@@ -8,7 +7,47 @@ pub(crate) struct RenderDownmixStats {
|
||||
pub clipped_samples: u64,
|
||||
}
|
||||
|
||||
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
||||
///
|
||||
/// The helper is allocation-free and safe for realtime render callbacks.
|
||||
/// If the stereo source is shorter than expected, the remainder of `out`
|
||||
/// is filled with silence.
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||
stereo: &[f32],
|
||||
out: &mut [i16],
|
||||
gain: f32,
|
||||
muted: bool,
|
||||
) -> RenderDownmixStats {
|
||||
if muted {
|
||||
out.fill(0);
|
||||
return RenderDownmixStats::default();
|
||||
}
|
||||
|
||||
let available_frames = stereo.len() / 2;
|
||||
if available_frames < out.len() {
|
||||
out.fill(0);
|
||||
}
|
||||
|
||||
let mut peak = 0_u16;
|
||||
let mut clipped_samples = 0_u64;
|
||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
||||
let clamped = mono.clamp(-1.0, 1.0);
|
||||
if (mono - clamped).abs() > f32::EPSILON {
|
||||
clipped_samples = clipped_samples.saturating_add(1);
|
||||
}
|
||||
let sample = (clamped * i16::MAX as f32) as i16;
|
||||
*dst = sample;
|
||||
peak = peak.max(sample.unsigned_abs());
|
||||
}
|
||||
|
||||
RenderDownmixStats {
|
||||
peak_i16: peak.min(i16::MAX as u16) as i16,
|
||||
clipped_samples,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
||||
stereo: &[f32],
|
||||
out: &mut [i16],
|
||||
@@ -83,7 +122,10 @@ pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
|
||||
if threshold <= 0.0 || !threshold.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||
let peak = samples
|
||||
.iter()
|
||||
.map(|s| s.abs())
|
||||
.fold(0.0_f32, f32::max);
|
||||
if peak <= threshold {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -103,7 +145,7 @@ mod tests {
|
||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||
let mut out = [0_i16; 3];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
||||
|
||||
assert_eq!(out[0], i16::MAX);
|
||||
assert_eq!(out[1], 0);
|
||||
@@ -117,7 +159,7 @@ mod tests {
|
||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||
let mut out = [123_i16; 2];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
||||
|
||||
assert_eq!(out, [0, 0]);
|
||||
assert_eq!(stats, RenderDownmixStats::default());
|
||||
@@ -192,7 +234,7 @@ mod tests {
|
||||
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
||||
limit_peak_inplace(&mut scratch, 0.95);
|
||||
let mut out = [0_i16; 3];
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
|
||||
assert_eq!(stats.clipped_samples, 0);
|
||||
assert!(stats.peak_i16 < i16::MAX);
|
||||
}
|
||||
|
||||
@@ -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::{PttBinding, PttInputClass};
|
||||
use chanora_audio::{select_ptt_backend, AudioTransmitGate};
|
||||
use chanora_audio::ptt_backends::{select_ptt_backend, PttBinding, PttInputClass};
|
||||
use chanora_audio::AudioTransmitGate;
|
||||
|
||||
let mut backend = select_ptt_backend();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
|
||||
@@ -1062,8 +1062,10 @@ pub enum BridgeAudioRoute {
|
||||
/// Bridge iOS voice-processing mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeIosVoiceProcessingMode {
|
||||
/// Apple VoiceProcessingIO path.
|
||||
/// Shipping VPIO path.
|
||||
PlatformVoiceProcessing,
|
||||
/// Experimental Sonora path.
|
||||
SonoraExperimental,
|
||||
}
|
||||
|
||||
/// Bridge processing backend.
|
||||
@@ -1221,6 +1223,7 @@ impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode
|
||||
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
||||
match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1231,6 +1234,7 @@ impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode
|
||||
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||
Self::PlatformVoiceProcessing
|
||||
}
|
||||
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1465,54 +1469,6 @@ 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 {
|
||||
@@ -1739,8 +1695,6 @@ 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 {
|
||||
@@ -1858,27 +1812,6 @@ 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 {
|
||||
@@ -2025,13 +1958,11 @@ 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 }
|
||||
@@ -2041,77 +1972,27 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
route: route.into(),
|
||||
}
|
||||
}
|
||||
chanora_core::SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} => BridgeEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => BridgeEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
|
||||
BridgeEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
||||
BridgeEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => BridgeEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
chanora_core::SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => BridgeEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
|
||||
chanora_core::SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => BridgeEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelRemoved { id } => {
|
||||
BridgeEvent::ChannelRemoved { id }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2405,11 +2286,25 @@ pub async fn set_ios_voice_processing_mode(
|
||||
let config = BridgeAudioProcessingConfig {
|
||||
route: BridgeAudioRoute::Speaker,
|
||||
ios_mode: mode,
|
||||
processing_backend: BridgeAudioBackend::PlatformVoiceProcessing,
|
||||
processing_backend: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||
BridgeAudioBackend::PlatformVoiceProcessing
|
||||
}
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
|
||||
},
|
||||
vad_backend: BridgeVadBackend::SileroOnnx,
|
||||
aec: BridgeEffectOwner::Platform,
|
||||
ns: BridgeEffectOwner::Platform,
|
||||
agc: BridgeEffectOwner::Platform,
|
||||
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,
|
||||
},
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
vad_hangover_ms: 500,
|
||||
|
||||
@@ -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 = 635684021;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
|
||||
|
||||
// Section: executor
|
||||
|
||||
@@ -186,41 +186,6 @@ 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,
|
||||
@@ -367,80 +332,6 @@ 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,
|
||||
@@ -543,41 +434,6 @@ 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,
|
||||
@@ -846,42 +702,6 @@ 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,
|
||||
@@ -2472,14 +2292,11 @@ 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 => {
|
||||
@@ -2589,6 +2406,7 @@ 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
|
||||
@@ -2636,19 +2454,6 @@ 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 {
|
||||
@@ -2875,17 +2680,6 @@ 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 {
|
||||
@@ -2943,17 +2737,6 @@ 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 {
|
||||
@@ -3007,50 +2790,45 @@ 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__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 => {
|
||||
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 => {
|
||||
wire__crate__api__set_ios_voice_processing_mode_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),
|
||||
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),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -3063,19 +2841,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 {
|
||||
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(
|
||||
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(
|
||||
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),
|
||||
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),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -3521,14 +3299,12 @@ 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 } => {
|
||||
@@ -3640,6 +3416,7 @@ 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!(),
|
||||
}
|
||||
}
|
||||
@@ -3707,28 +3484,6 @@ 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 {
|
||||
[
|
||||
@@ -4298,14 +4053,12 @@ 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);
|
||||
@@ -4409,6 +4162,7 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
|
||||
crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -4460,23 +4214,6 @@ 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) {
|
||||
@@ -4692,16 +4429,6 @@ 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) {
|
||||
@@ -4752,16 +4479,6 @@ 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) {
|
||||
|
||||
@@ -110,13 +110,9 @@ 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
[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"] }
|
||||
@@ -1,359 +0,0 @@
|
||||
//! Disposable content-addressed blob cache for avatar and icon files.
|
||||
//!
|
||||
//! Wraps [`cacache`] for production-tested crash safety and integrity
|
||||
//! verification. The on-disk layout is managed by cacache (content-v2,
|
||||
//! index-v2). Chanora maps protocol keys (`av_<md5>`, `ic_<crc32>`) to
|
||||
//! cacache string keys.
|
||||
//!
|
||||
//! This crate is intentionally separate from `chanora_storage`:
|
||||
//! storage owns persistent identity/bookmark data; cache owns
|
||||
//! reconstructible, disposable blob data with different durability
|
||||
//! and backup semantics.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Errors raised by the blob cache.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BlobCacheError {
|
||||
/// Filesystem I/O error.
|
||||
#[error("io: {0}")]
|
||||
Io(String),
|
||||
/// Key validation error.
|
||||
#[error("invalid key: {0}")]
|
||||
InvalidKey(String),
|
||||
}
|
||||
|
||||
/// Content-addressed blob cache backed by cacache.
|
||||
pub struct BlobCache {
|
||||
cache_dir: PathBuf,
|
||||
/// Maximum total cache size in bytes. 0 = no limit.
|
||||
max_bytes: u64,
|
||||
}
|
||||
|
||||
/// Avatar blob prefix.
|
||||
pub const PREFIX_AVATAR: &str = "av_";
|
||||
/// Icon blob prefix.
|
||||
pub const PREFIX_ICON: &str = "ic_";
|
||||
|
||||
impl BlobCache {
|
||||
/// Create or open a [`BlobCache`] rooted at `cache_dir/chanora/`.
|
||||
///
|
||||
/// Creates the cacache directory. `max_bytes` sets the eviction
|
||||
/// threshold; 0 means no automatic eviction.
|
||||
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError> {
|
||||
let cache_dir = cache_dir.as_ref().join("chanora").join("blobs");
|
||||
// cacache creates the directory on first write, but we create
|
||||
// it eagerly so total_size() works before any writes.
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| BlobCacheError::Io(format!("mkdir cache: {e}")))?;
|
||||
Ok(Self {
|
||||
cache_dir,
|
||||
max_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a blob. `prefix` is [`PREFIX_AVATAR`] or [`PREFIX_ICON`].
|
||||
/// `key` is the content hash (MD5 hex for avatars, unsigned
|
||||
/// decimal CRC32 for icons).
|
||||
///
|
||||
/// Cacache handles dedup and integrity internally.
|
||||
pub async fn put(
|
||||
&self,
|
||||
prefix: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), BlobCacheError> {
|
||||
validate_key(prefix, key)?;
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
cacache::write(&self.cache_dir, &cache_key, data)
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("cacache write: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a blob. Returns `None` if not cached.
|
||||
///
|
||||
/// Cacache verifies SSRI integrity on every read.
|
||||
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError> {
|
||||
validate_key(prefix, key)?;
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
match cacache::read(&self.cache_dir, &cache_key).await {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(cacache::Error::EntryNotFound(_, _)) => Ok(None),
|
||||
Err(e) => {
|
||||
// Integrity failure or I/O error — remove corrupt entry.
|
||||
tracing::warn!(
|
||||
target: "chanora_cache",
|
||||
key = %cache_key,
|
||||
error = %e,
|
||||
"cache read failed; removing entry"
|
||||
);
|
||||
let _ = cacache::remove(&self.cache_dir, &cache_key).await;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a specific blob.
|
||||
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError> {
|
||||
validate_key(prefix, key)?;
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
cacache::remove(&self.cache_dir, &cache_key)
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("cacache remove: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all blobs.
|
||||
pub async fn clear(&self) -> Result<(), BlobCacheError> {
|
||||
let path = self.cache_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if path.exists() {
|
||||
std::fs::remove_dir_all(&path)
|
||||
.map_err(|e| BlobCacheError::Io(format!("clear cache: {e}")))?;
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| BlobCacheError::Io(format!("recreate cache dir: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("clear task: {e}")))?
|
||||
}
|
||||
|
||||
/// Return total bytes used by all blobs.
|
||||
///
|
||||
/// Walks cacache entries and sums sizes.
|
||||
pub async fn total_size(&self) -> Result<u64, BlobCacheError> {
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut total: u64 = 0;
|
||||
for entry in cacache::list_sync(&cache_dir) {
|
||||
match entry {
|
||||
Ok(meta) => total += meta.size as u64,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_cache",
|
||||
error = %e,
|
||||
"skipping bad entry during size scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("total_size task: {e}")))?
|
||||
}
|
||||
|
||||
/// Evict oldest entries by timestamp until total size is under
|
||||
/// `max_bytes`. Call on startup or periodically. No-op if
|
||||
/// `max_bytes` is 0.
|
||||
pub async fn evict(&self) -> Result<(), BlobCacheError> {
|
||||
if self.max_bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
let max_bytes = self.max_bytes;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut entries: Vec<(String, usize, u128)> = Vec::new();
|
||||
for entry in cacache::list_sync(&cache_dir) {
|
||||
match entry {
|
||||
Ok(meta) => {
|
||||
entries.push((meta.key, meta.size, meta.time));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_cache",
|
||||
error = %e,
|
||||
"skipping bad entry during eviction scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let total: usize = entries.iter().map(|(_, s, _)| *s).sum();
|
||||
if total as u64 <= max_bytes {
|
||||
return Ok(());
|
||||
}
|
||||
entries.sort_by_key(|(_, _, t)| *t);
|
||||
let mut freed: usize = 0;
|
||||
let target = total - max_bytes as usize;
|
||||
for (key, size, _) in entries {
|
||||
if freed >= target {
|
||||
break;
|
||||
}
|
||||
let _ = cacache::remove_sync(&cache_dir, &key);
|
||||
freed += size;
|
||||
}
|
||||
tracing::info!(
|
||||
target: "chanora_cache",
|
||||
freed_bytes = freed,
|
||||
"evicted oldest blobs"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("evict task: {e}")))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate key format to prevent malformed entries.
|
||||
fn validate_key(prefix: &str, key: &str) -> Result<(), BlobCacheError> {
|
||||
if !matches!(prefix, PREFIX_AVATAR | PREFIX_ICON) {
|
||||
return Err(BlobCacheError::InvalidKey(format!("bad prefix: {prefix}")));
|
||||
}
|
||||
match prefix {
|
||||
PREFIX_AVATAR => {
|
||||
// MD5 hex = exactly 32 hex chars.
|
||||
if key.len() != 32 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(BlobCacheError::InvalidKey(format!(
|
||||
"avatar key must be 32 hex chars, got: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
PREFIX_ICON => {
|
||||
// Unsigned CRC32 = decimal digits.
|
||||
if key.is_empty() || !key.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(BlobCacheError::InvalidKey(format!(
|
||||
"icon key must be decimal digits, got: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
tempfile::Builder::new()
|
||||
.prefix("chanora_cache_test_")
|
||||
.tempdir()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_roundtrip() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar-bytes")
|
||||
.await
|
||||
.unwrap();
|
||||
let data = cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data.as_deref(), Some(b"avatar-bytes".as_slice()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_none() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "00000000000000000000000000000000")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_removes_all() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
|
||||
.await
|
||||
.unwrap();
|
||||
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
|
||||
cache.clear().await.unwrap();
|
||||
assert_eq!(cache.total_size().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn total_size_accounts_for_all_entries() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"12345")
|
||||
.await
|
||||
.unwrap();
|
||||
cache.put(PREFIX_ICON, "99999", b"12").await.unwrap();
|
||||
assert_eq!(cache.total_size().await.unwrap(), 5 + 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_key_rejected() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
// Too short for MD5.
|
||||
assert!(cache.put(PREFIX_AVATAR, "abc", b"data").await.is_err());
|
||||
// Non-hex in MD5.
|
||||
assert!(cache
|
||||
.put(PREFIX_AVATAR, "g".repeat(32).as_str(), b"data")
|
||||
.await
|
||||
.is_err());
|
||||
// Non-digit in icon key.
|
||||
assert!(cache.put(PREFIX_ICON, "12a45", b"data").await.is_err());
|
||||
// Bad prefix.
|
||||
assert!(cache.put("xx_", "abc", b"data").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evict_deletes_oldest_until_under_cap() {
|
||||
let tmp = tempdir();
|
||||
// 10 byte cap.
|
||||
let cache = BlobCache::new(&tmp, 10).unwrap();
|
||||
cache
|
||||
.put(
|
||||
PREFIX_AVATAR,
|
||||
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||
b"12345678",
|
||||
)
|
||||
.await
|
||||
.unwrap(); // 8 bytes
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
cache
|
||||
.put(PREFIX_ICON, "11111", b"12345")
|
||||
.await
|
||||
.unwrap(); // 5 bytes → total 13, over cap
|
||||
cache.evict().await.unwrap();
|
||||
// Oldest (avatar) should be evicted.
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(cache.get(PREFIX_ICON, "11111").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_deletes_entry() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
|
||||
.await
|
||||
.unwrap();
|
||||
cache
|
||||
.remove(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
@@ -712,7 +712,7 @@ impl DiagnosticExport {
|
||||
/// diagnostic export and state-sync replay verification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtocolEventRecorder {
|
||||
events: VecDeque<String>,
|
||||
events: Vec<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
@@ -720,20 +720,17 @@ impl ProtocolEventRecorder {
|
||||
/// Create a recorder with the given ring-buffer capacity.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
events: VecDeque::with_capacity(capacity),
|
||||
events: Vec::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
||||
if self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
let s = format!("[{ts}] {kind}: {detail}");
|
||||
if self.events.len() >= self.capacity {
|
||||
self.events.pop_front();
|
||||
self.events.remove(0);
|
||||
}
|
||||
self.events.push_back(s);
|
||||
self.events.push(s);
|
||||
}
|
||||
|
||||
/// Record a successful connection.
|
||||
@@ -780,12 +777,12 @@ impl ProtocolEventRecorder {
|
||||
|
||||
/// Drain all recorded events and reset the buffer.
|
||||
pub fn drain(&mut self) -> Vec<String> {
|
||||
self.events.drain(..).collect()
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Snapshot all recorded events without clearing the buffer.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.events.iter().cloned().collect()
|
||||
self.events.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,13 +1103,4 @@ mod tests {
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(drained, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_event_zero_capacity_drops_events() {
|
||||
let mut recorder = ProtocolEventRecorder::new(0);
|
||||
recorder.record_connected("Server");
|
||||
|
||||
assert!(recorder.snapshot().is_empty());
|
||||
assert!(recorder.drain().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ use base64::prelude::*;
|
||||
use chanora_resolver::ChanoraResolver;
|
||||
use futures::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -33,8 +32,7 @@ use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
|
||||
use tsclientlib::prelude::*;
|
||||
use tsclientlib::{
|
||||
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
|
||||
DisconnectOptions, FileDownloadResult, FiletransferHandle, Identity, MessageHandle,
|
||||
OutCommandExt, StreamItem, Version,
|
||||
DisconnectOptions, Identity, MessageHandle, OutCommandExt, StreamItem, Version,
|
||||
};
|
||||
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
|
||||
use tsproto_types::ClientType;
|
||||
@@ -43,15 +41,11 @@ use crate::dto::{
|
||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
use crate::poke_limiter::PokeLimiter;
|
||||
use crate::ProtocolError;
|
||||
|
||||
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
||||
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const OUTBOUND_VOICE_PACKETS_PER_TICK: usize = 8;
|
||||
const DISCONNECT_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const DISCONNECT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
type PendingMoves = HashMap<
|
||||
MessageHandle,
|
||||
@@ -62,9 +56,6 @@ type PendingMoves = HashMap<
|
||||
),
|
||||
>;
|
||||
|
||||
type PendingDownloads =
|
||||
HashMap<FiletransferHandle, oneshot::Sender<Result<Vec<u8>, ProtocolError>>>;
|
||||
|
||||
struct EventChannels {
|
||||
voice_in: mpsc::Sender<InboundVoice>,
|
||||
chat: mpsc::Sender<ChatMessage>,
|
||||
@@ -93,30 +84,6 @@ async fn send_with_timeout<T: Send>(
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_voice_packets_for_tick<T, E>(
|
||||
voice_out_rx: &mut mpsc::Receiver<T>,
|
||||
max_packets: usize,
|
||||
mut send: impl FnMut(T) -> Result<(), E>,
|
||||
) -> usize {
|
||||
let mut drained = 0;
|
||||
for _ in 0..max_packets {
|
||||
let packet = match voice_out_rx.try_recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
let _ = send(packet);
|
||||
drained += 1;
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
|
||||
where
|
||||
S: futures::Stream,
|
||||
{
|
||||
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
|
||||
}
|
||||
|
||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
||||
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
||||
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
||||
@@ -212,10 +179,6 @@ enum Request {
|
||||
client_id: u64,
|
||||
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
|
||||
},
|
||||
DownloadFile {
|
||||
path: String,
|
||||
reply: oneshot::Sender<Result<Vec<u8>, ProtocolError>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||
@@ -375,44 +338,11 @@ impl ProtocolClient {
|
||||
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
async fn download_file(&self, path: String) -> Result<Vec<u8>, ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::DownloadFile { path, reply: tx })
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
/// Download the current avatar bytes for a TeamSpeak client UID.
|
||||
pub async fn download_avatar(&self, client_uid: &str) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.download_file(avatar_download_path(client_uid)).await
|
||||
}
|
||||
|
||||
/// Download the current channel/server icon bytes for an icon id.
|
||||
pub async fn download_icon(&self, icon_id: u64) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.download_file(icon_download_path(icon_id)).await
|
||||
}
|
||||
|
||||
/// Disconnect cleanly. Blocks until the task exits.
|
||||
pub async fn disconnect(self) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let request_path = async {
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
}
|
||||
};
|
||||
|
||||
if tokio::time::timeout(DISCONNECT_REPLY_TIMEOUT, request_path)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_protocol",
|
||||
timeout_ms = DISCONNECT_REPLY_TIMEOUT.as_millis() as u64,
|
||||
"disconnect request did not complete before timeout"
|
||||
);
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,20 +672,16 @@ async fn connection_task(
|
||||
// deadline so a server that never replies doesn't leak the
|
||||
// reply channel — at most 3 s of pending state per move.
|
||||
let mut pending_moves: PendingMoves = HashMap::new();
|
||||
let mut pending_downloads: PendingDownloads = HashMap::new();
|
||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||
let mut poke_limiter = PokeLimiter::new();
|
||||
|
||||
// Main loop: pump events, service requests, forward voice.
|
||||
loop {
|
||||
// 1. Send a bounded batch of outbound voice packets first — they're
|
||||
// time-sensitive, but control requests must still make progress.
|
||||
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
|
||||
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||
while let Ok(pkt) = voice_out_rx.try_recv() {
|
||||
if let Err(e) = con.send_audio(pkt) {
|
||||
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
||||
}
|
||||
Ok::<(), ()>(())
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Advance event stream by at most one event with a small timeout.
|
||||
let pump = async {
|
||||
@@ -763,26 +689,21 @@ async fn connection_task(
|
||||
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
||||
};
|
||||
match pump.await {
|
||||
Ok(Some(Ok(item))) => match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
Ok(Some(Ok(item))) => {
|
||||
match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
&mut pending_moves,
|
||||
),
|
||||
}
|
||||
StreamItem::FileDownload(handle, result) => {
|
||||
handle_download_stream_item(&mut pending_downloads, handle, result).await;
|
||||
}
|
||||
StreamItem::FiletransferFailed(handle, error) => {
|
||||
handle_download_failure(&mut pending_downloads, handle, error);
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
&mut pending_moves,
|
||||
&mut poke_limiter,
|
||||
),
|
||||
},
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||
// Some errors are transient; treat persistent ones
|
||||
@@ -879,28 +800,14 @@ async fn connection_task(
|
||||
client_id,
|
||||
&channels,
|
||||
&mut pending_moves,
|
||||
&mut pending_downloads,
|
||||
&mut voice_activity,
|
||||
&mut poke_limiter,
|
||||
)
|
||||
.await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Ok(Request::DownloadFile { path, reply }) => {
|
||||
match con.download_file(TsChannelId(0), &path, None, None) {
|
||||
Ok(handle) => {
|
||||
pending_downloads.insert(handle, reply);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(ProtocolError::FileTransfer(format!(
|
||||
"start download {path}: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
let _ = reply.send(());
|
||||
info!(target: "chanora_protocol", "clean disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
@@ -908,7 +815,7 @@ async fn connection_task(
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
@@ -956,7 +863,6 @@ fn handle_non_audio_stream_item(
|
||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||
pending_moves: &mut PendingMoves,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) {
|
||||
match item {
|
||||
StreamItem::BookEvents(events) => {
|
||||
@@ -1018,25 +924,17 @@ fn handle_non_audio_stream_item(
|
||||
message,
|
||||
} = ev
|
||||
{
|
||||
let (mapped, poke_strength) = match target {
|
||||
tsclientlib::MessageTarget::Server => (MessageTarget::Server, None),
|
||||
tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None),
|
||||
tsclientlib::MessageTarget::Client(id) => {
|
||||
(MessageTarget::Client(id.0 as u64), None)
|
||||
}
|
||||
tsclientlib::MessageTarget::Poke(id) => {
|
||||
let own_client_id =
|
||||
con.get_state().ok().map(|state| state.own_client.0 as u64);
|
||||
let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id);
|
||||
(MessageTarget::Poke(id.0 as u64), Some(strength))
|
||||
}
|
||||
let mapped = match target {
|
||||
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
||||
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
||||
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64),
|
||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
||||
};
|
||||
let _ = chat_tx.try_send(ChatMessage {
|
||||
sender_id: ClientId(invoker.id.0 as u64),
|
||||
sender_name: sanitize(&invoker.name),
|
||||
message: sanitize(&message),
|
||||
target: mapped,
|
||||
poke_strength,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1073,47 +971,6 @@ fn handle_non_audio_stream_item(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_download_stream_item(
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
handle: FiletransferHandle,
|
||||
result: FileDownloadResult,
|
||||
) {
|
||||
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||
let _ = reply.send(read_download_bytes(result).await);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_download_failure(
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
handle: FiletransferHandle,
|
||||
error: tsclientlib::Error,
|
||||
) {
|
||||
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||
let _ = reply.send(Err(ProtocolError::FileTransfer(error.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DOWNLOAD_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
async fn read_download_bytes(result: FileDownloadResult) -> Result<Vec<u8>, ProtocolError> {
|
||||
if result.size > MAX_DOWNLOAD_SIZE {
|
||||
return Err(ProtocolError::FileTransfer(format!(
|
||||
"download too large: {} bytes (max {})",
|
||||
result.size, MAX_DOWNLOAD_SIZE
|
||||
)));
|
||||
}
|
||||
let size = usize::try_from(result.size).map_err(|_| {
|
||||
ProtocolError::FileTransfer(format!("download too large to buffer: {} bytes", result.size))
|
||||
})?;
|
||||
let mut stream = result.stream;
|
||||
let mut bytes = vec![0_u8; size];
|
||||
stream
|
||||
.read_exact(&mut bytes)
|
||||
.await
|
||||
.map_err(|e| ProtocolError::FileTransfer(e.to_string()))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
|
||||
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
|
||||
host: address.to_string(),
|
||||
@@ -1264,21 +1121,11 @@ async fn fetch_client_profile(
|
||||
client_id: u64,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<ClientProfile, ProtocolError> {
|
||||
let target_id = TsClientId(client_id as u16);
|
||||
|
||||
let (
|
||||
database_id,
|
||||
uid_b64,
|
||||
has_optional,
|
||||
has_connection,
|
||||
is_own,
|
||||
needs_server_groups,
|
||||
needs_channel_groups,
|
||||
) = {
|
||||
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
@@ -1311,9 +1158,7 @@ async fn fetch_client_profile(
|
||||
build_command("servergrouplist", &[], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1323,9 +1168,7 @@ async fn fetch_client_profile(
|
||||
build_command("channelgrouplist", &[], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1339,9 +1182,7 @@ async fn fetch_client_profile(
|
||||
),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1359,9 +1200,7 @@ async fn fetch_client_profile(
|
||||
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1380,9 +1219,7 @@ async fn fetch_client_profile(
|
||||
database_id,
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
@@ -1452,18 +1289,10 @@ async fn fetch_client_profile(
|
||||
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
||||
last_connected_unix_seconds: optional
|
||||
.map(|info| info.last_connected.unix_timestamp())
|
||||
.or_else(|| {
|
||||
db_info
|
||||
.as_ref()
|
||||
.map(|info| info.last_connected.unix_timestamp())
|
||||
}),
|
||||
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())),
|
||||
connections_total: optional
|
||||
.map(|info| u64::from(info.connections_total))
|
||||
.or_else(|| {
|
||||
db_info
|
||||
.as_ref()
|
||||
.map(|info| u64::from(info.connections_total))
|
||||
}),
|
||||
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))),
|
||||
online_seconds: connection
|
||||
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
||||
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
||||
@@ -1496,10 +1325,14 @@ async fn fetch_client_profile(
|
||||
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
||||
packet_loss_client_to_server_total: net_stats
|
||||
.map(|s| s.get_packetloss())
|
||||
.or_else(|| connection.map(|info| info.client_to_server_packetloss_total)),
|
||||
.or_else(|| {
|
||||
connection.map(|info| info.client_to_server_packetloss_total)
|
||||
}),
|
||||
packet_loss_server_to_client_total: net_stats
|
||||
.map(|s| s.get_packetloss_s2c_total())
|
||||
.or_else(|| connection.and_then(|info| info.server_to_client_packetloss_total)),
|
||||
.or_else(|| {
|
||||
connection.and_then(|info| info.server_to_client_packetloss_total)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1544,9 +1377,7 @@ async fn request_messages(
|
||||
command: OutCommand,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||
let handle = command
|
||||
.send_with_result(con)
|
||||
@@ -1582,12 +1413,6 @@ async fn request_messages(
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
||||
}
|
||||
StreamItem::FileDownload(handle, result) => {
|
||||
handle_download_stream_item(pending_downloads, handle, result).await;
|
||||
}
|
||||
StreamItem::FiletransferFailed(handle, error) => {
|
||||
handle_download_failure(pending_downloads, handle, error);
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
con,
|
||||
other,
|
||||
@@ -1595,7 +1420,6 @@ async fn request_messages(
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
pending_moves,
|
||||
poke_limiter,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1606,18 +1430,14 @@ async fn request_client_db_info(
|
||||
dbid: tsclientlib::ClientDbId,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||
let messages = request_messages(
|
||||
con,
|
||||
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
.await?;
|
||||
for message in messages {
|
||||
@@ -1673,14 +1493,6 @@ fn uid_to_avatar_path(uid_b64: &str) -> String {
|
||||
rendered
|
||||
}
|
||||
|
||||
fn avatar_download_path(client_uid: &str) -> String {
|
||||
format!("/avatar_{}", uid_to_avatar_path(client_uid))
|
||||
}
|
||||
|
||||
fn icon_download_path(icon_id: u64) -> String {
|
||||
format!("/icon_{icon_id}")
|
||||
}
|
||||
|
||||
fn find_client_by_id<'a>(
|
||||
clients: impl IntoIterator<Item = &'a Client>,
|
||||
client_id: u64,
|
||||
@@ -2064,13 +1876,10 @@ const _: () = {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
avatar_download_path, bounded_drain_stream, client_profile_refresh_plan,
|
||||
drain_voice_packets_for_tick, icon_download_path, is_server_query_client_type,
|
||||
send_with_timeout, server_socket_from_config, sort_channels_tree_by,
|
||||
std_duration_millis, ConnectConfig, ProtocolClient, Request, SendTimeoutError,
|
||||
DISCONNECT_REPLY_TIMEOUT,
|
||||
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout,
|
||||
server_socket_from_config, sort_channels_tree_by, std_duration_millis,
|
||||
ConnectConfig, SendTimeoutError,
|
||||
};
|
||||
use futures::stream;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tsproto_types::ClientType;
|
||||
@@ -2166,16 +1975,6 @@ mod tests {
|
||||
assert!(plan.needs_channel_groups);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_download_path_uses_uid_hex_encoding() {
|
||||
assert_eq!(avatar_download_path("AQID"), "/avatar_abacad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_download_path_uses_unsigned_icon_id() {
|
||||
assert_eq!(icon_download_path(42), "/icon_42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_sort_linked_list_under_one_parent() {
|
||||
// Server emits four root-level channels in arbitrary HashMap
|
||||
@@ -2323,83 +2122,6 @@ mod tests {
|
||||
|
||||
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_request_send_is_bounded_when_request_channel_is_full() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(Request::Snapshot(reply_tx))
|
||||
.await
|
||||
.expect("seed first request");
|
||||
|
||||
let (disconnect_tx, _disconnect_rx) = tokio::sync::oneshot::channel();
|
||||
let result = send_with_timeout(
|
||||
&tx,
|
||||
Request::Disconnect(disconnect_tx),
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SendTimeoutError::Timeout(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_client_disconnect_returns_when_request_channel_is_full() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let (snapshot_tx, _snapshot_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(Request::Snapshot(snapshot_tx))
|
||||
.await
|
||||
.expect("seed first request");
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||
let (_voice_in_tx, voice_in_rx) = mpsc::channel(1);
|
||||
let (_lost_tx, lost_rx) = tokio::sync::oneshot::channel();
|
||||
let (_chat_tx, chat_rx) = mpsc::channel(1);
|
||||
let (_activity_tx, activity_rx) = mpsc::channel(1);
|
||||
let (_delta_tx, delta_rx) = mpsc::channel(1);
|
||||
let client = ProtocolClient {
|
||||
tx,
|
||||
voice_out_tx,
|
||||
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
|
||||
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
|
||||
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
|
||||
};
|
||||
|
||||
tokio::time::timeout(
|
||||
DISCONNECT_REPLY_TIMEOUT + Duration::from_millis(100),
|
||||
client.disconnect(),
|
||||
)
|
||||
.await
|
||||
.expect("disconnect should not wait indefinitely for request channel capacity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_drain_stops_at_per_tick_budget() {
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
for value in 0_u8..5 {
|
||||
tx.send(value).await.expect("seed voice packet");
|
||||
}
|
||||
|
||||
let mut sent = Vec::new();
|
||||
let drained = drain_voice_packets_for_tick(&mut rx, 2, |value| {
|
||||
sent.push(value);
|
||||
Ok::<(), ()>(())
|
||||
});
|
||||
|
||||
assert_eq!(drained, 2);
|
||||
assert_eq!(sent, vec![0, 1]);
|
||||
assert_eq!(rx.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_stream_drain_returns_after_timeout() {
|
||||
let start = tokio::time::Instant::now();
|
||||
|
||||
bounded_drain_stream(stream::pending::<()>(), Duration::from_millis(10)).await;
|
||||
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_delta(
|
||||
@@ -2482,7 +2204,9 @@ fn forward_delta(
|
||||
old: PropertyValue::Channel(channel),
|
||||
..
|
||||
} => {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
||||
id: channel.id.0,
|
||||
});
|
||||
}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::Channel(channel_id),
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::poke_limiter::PokeStrength;
|
||||
|
||||
/// Opaque server-side channel identifier. Internal representation is
|
||||
/// the upstream u64 but callers must treat it as opaque.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -56,8 +54,6 @@ pub struct ChatMessage {
|
||||
pub message: String,
|
||||
/// Target scope of this message.
|
||||
pub target: MessageTarget,
|
||||
/// Strength classification for poke notifications.
|
||||
pub poke_strength: Option<PokeStrength>,
|
||||
}
|
||||
|
||||
/// A server-activity notification derived from TeamSpeak bookkeeping events.
|
||||
|
||||
@@ -35,14 +35,12 @@
|
||||
|
||||
mod adapter;
|
||||
mod dto;
|
||||
pub mod poke_limiter;
|
||||
|
||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||
pub use dto::{
|
||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||
PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use poke_limiter::PokeLimiter;
|
||||
|
||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||
// voice packets without taking a direct dependency on tsclientlib /
|
||||
@@ -115,8 +113,4 @@ pub enum ProtocolError {
|
||||
/// should never see this; if they do, it is a mapping bug here.
|
||||
#[error("protocol backend: {0}")]
|
||||
Backend(String),
|
||||
|
||||
/// A file transfer failed while downloading protocol-owned assets.
|
||||
#[error("file transfer failed: {0}")]
|
||||
FileTransfer(String),
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
//! Per-connection poke strength classification.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Notification strength assigned to an inbound poke.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PokeStrength {
|
||||
/// Poke should be surfaced at full strength.
|
||||
Strong,
|
||||
/// Poke is rate-limited but below overflow severity.
|
||||
Suppressed,
|
||||
/// Poke remains suppressed after repeated suppressed pokes.
|
||||
SuppressedOverflow,
|
||||
}
|
||||
|
||||
/// Per-connection poke limiter.
|
||||
#[derive(Debug)]
|
||||
pub struct PokeLimiter {
|
||||
window: Duration,
|
||||
entries: HashMap<u64, PokeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PokeEntry {
|
||||
tokens: u8,
|
||||
last_refill: Instant,
|
||||
suppressed_in_window: u8,
|
||||
}
|
||||
|
||||
impl PokeLimiter {
|
||||
const CAPACITY: u8 = 2;
|
||||
const OVERFLOW_THRESHOLD: u8 = 3;
|
||||
|
||||
/// Create a limiter using the default five-minute refill interval.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
window: Duration::from_secs(5 * 60),
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a poke at the current instant.
|
||||
pub fn record(&mut self, sender_id: u64, own_client_id: Option<u64>) -> PokeStrength {
|
||||
self.record_at(sender_id, own_client_id, Instant::now())
|
||||
}
|
||||
|
||||
/// Record a poke at an injected instant.
|
||||
pub fn record_at(
|
||||
&mut self,
|
||||
sender_id: u64,
|
||||
own_client_id: Option<u64>,
|
||||
now: Instant,
|
||||
) -> PokeStrength {
|
||||
if own_client_id == Some(sender_id) {
|
||||
return PokeStrength::Suppressed;
|
||||
}
|
||||
|
||||
let entry = self.entries.entry(sender_id).or_insert(PokeEntry {
|
||||
tokens: Self::CAPACITY,
|
||||
last_refill: now,
|
||||
suppressed_in_window: 0,
|
||||
});
|
||||
|
||||
if now.duration_since(entry.last_refill) >= self.window {
|
||||
entry.tokens = Self::CAPACITY;
|
||||
entry.last_refill = now;
|
||||
entry.suppressed_in_window = 0;
|
||||
}
|
||||
|
||||
if entry.tokens > 0 {
|
||||
entry.tokens -= 1;
|
||||
return PokeStrength::Strong;
|
||||
}
|
||||
|
||||
entry.suppressed_in_window = entry.suppressed_in_window.saturating_add(1);
|
||||
if entry.suppressed_in_window >= Self::OVERFLOW_THRESHOLD {
|
||||
PokeStrength::SuppressedOverflow
|
||||
} else {
|
||||
PokeStrength::Suppressed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PokeLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_two_pokes_are_strong_third_is_suppressed() {
|
||||
let mut limiter = PokeLimiter::new();
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||
PokeStrength::Suppressed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_poke_is_suppressed_without_consuming_token() {
|
||||
let mut limiter = PokeLimiter::new();
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(limiter.record_at(7, Some(7), now), PokeStrength::Suppressed);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_after_three_suppressed_pokes_in_five_minutes() {
|
||||
let mut limiter = PokeLimiter::new();
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||
PokeStrength::Suppressed
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(3)),
|
||||
PokeStrength::Suppressed
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(4)),
|
||||
PokeStrength::SuppressedOverflow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refill_after_interval_uses_injected_time_without_sleeping() {
|
||||
let mut limiter = PokeLimiter::new();
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||
PokeStrength::Suppressed
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
assert_eq!(
|
||||
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60 + 1)),
|
||||
PokeStrength::Strong
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "chanora_resolver"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -346,7 +346,9 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
|
||||
if removed_channel {
|
||||
deltas.push(Delta::ChannelRemoved(id));
|
||||
}
|
||||
Reduction { deltas }
|
||||
Reduction {
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
@@ -410,7 +412,14 @@ pub fn reduce_reconnect_snapshot(
|
||||
state: &mut Option<ServerState>,
|
||||
snap: ServerSnapshot,
|
||||
) -> Reduction {
|
||||
reduce(state, StateEvent::Snapshot(snap))
|
||||
let normalized = normalize_snapshot(snap);
|
||||
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
||||
Reduction {
|
||||
deltas: vec![
|
||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||
Delta::SnapshotApplied(normalized),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -615,7 +624,6 @@ mod tests {
|
||||
sender_name: "Alice".into(),
|
||||
message: "hello".into(),
|
||||
target: MessageTarget::Channel,
|
||||
poke_strength: None,
|
||||
};
|
||||
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
|
||||
assert!(disconnected.deltas.is_empty());
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# SAD Component → Source File Mapping
|
||||
|
||||
**Purpose:** Developer convenience mapping from ASPICE architecture components to source file locations. This is NOT an ASPICE document — it's a lookup for developers.
|
||||
|
||||
## Component Mapping
|
||||
|
||||
| SAD Component | Source Location |
|
||||
|---|---|
|
||||
| Flutter app shell | `apps/chanora_flutter/lib/main.dart`, services, widgets |
|
||||
| Flutter service layer | `apps/chanora_flutter/lib/services/` |
|
||||
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` |
|
||||
| Bridge layer | `crates/chanora_bridge/src/api.rs`, `apps/chanora_flutter/lib/src/rust/` |
|
||||
| Rust core | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` |
|
||||
| Protocol adapter | `crates/chanora_protocol/src/` |
|
||||
| State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` |
|
||||
| Audio subsystem | `crates/chanora_audio/src/` |
|
||||
| Storage | `crates/chanora_storage/src/lib.rs` |
|
||||
| Diagnostics | `crates/chanora_diagnostics/src/lib.rs` |
|
||||
| Resolution and prefetch | `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` |
|
||||
| Build and release hooks | `.github/workflows/`, `tools/`, platform project files |
|
||||
|
||||
## SDD Module Mapping
|
||||
|
||||
| SDD Module | Source Location |
|
||||
|---|---|
|
||||
| SDD-MOD-001 Flutter app bootstrap | `apps/chanora_flutter/lib/services/app_bootstrap.dart`, `main.dart` |
|
||||
| SDD-MOD-002 Connect UI | `apps/chanora_flutter/lib/widgets/connect_widgets.dart` |
|
||||
| SDD-MOD-003 Snapshot and channel UI | `snapshot_view.dart`, `snapshot_state_mapper.dart`, `channel_spacer.dart` |
|
||||
| SDD-MOD-004 Chat UI | `chat_views.dart`, `bbcode_text.dart` |
|
||||
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` |
|
||||
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` |
|
||||
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files |
|
||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` |
|
||||
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` |
|
||||
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` |
|
||||
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` |
|
||||
| SDD-MOD-012 Storage | `crates/chanora_storage/src/lib.rs` |
|
||||
| SDD-MOD-013 Diagnostics | `crates/chanora_diagnostics/src/lib.rs` |
|
||||
| SDD-MOD-014 Resolution and prefetch | `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` |
|
||||
| SDD-MOD-015 Build and release hooks | `.github/workflows/`, `tools/`, platform project files |
|
||||
@@ -1,75 +0,0 @@
|
||||
# Chanora Offline Knowledge Library
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
**Branch:** `docs/offline-knowledge-library-2026-06-13`
|
||||
**Purpose:** Comprehensive offline reference for the Chanora project, its dependencies, and related ecosystem.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
### Project Analysis
|
||||
|
||||
| Document | Description | Status |
|
||||
|----------|-------------|--------|
|
||||
| [function-inventory.md](function-inventory.md) | Complete public API inventory for all 10 Rust crates + 56 Dart files. Includes dead code analysis. | Reviewed |
|
||||
| [coverage-analysis.md](coverage-analysis.md) | Test coverage (312 Rust tests, 221 Dart tests) and documentation coverage gaps. | Reviewed, corrected |
|
||||
| [doc-quality-analysis.md](doc-quality-analysis.md) | Duplicated content, useless content, and broken references in docs/. | Reviewed, corrected |
|
||||
| [link-coverage-report.md](link-coverage-report.md) | All internal/external links validated. 2 broken LICENSE links, 5 broken doc-path refs. | Reviewed, corrected |
|
||||
| [docs-code-mismatch.md](docs-code-mismatch.md) | 17 doc-code mismatches found (2 critical, 4 major, 11 minor). | Reviewed, corrected |
|
||||
| [docs-out-of-date.md](docs-out-of-date.md) | 12 outdated docs, 8 undocumented recent changes since DV baseline. | Reviewed, corrected |
|
||||
| [docs-link-not-covered.md](docs-link-not-covered.md) | 2 broken links, 9 missing targets, 18 orphaned docs. | Reviewed, corrected |
|
||||
|
||||
### External Projects
|
||||
|
||||
| Document | Description | Status |
|
||||
|----------|-------------|--------|
|
||||
| [external/teaspeak-overview.md](external/teaspeak-overview.md) | TeaSpeak voice server - architecture, protocol, build system. | Reviewed |
|
||||
| [external/respeak-overview.md](external/respeak-overview.md) | ReSpeak org - tsclientlib, tsproto, crypto, Chanora integration. | Reviewed |
|
||||
| [external/yatqa-en.md](external/yatqa-en.md) | yat.qa TeamSpeak admin tool (English). | Reviewed |
|
||||
| [external/yatqa-de.md](external/yatqa-de.md) | yat.qa TeamSpeak admin tool (German/Deutsch). | Reviewed |
|
||||
|
||||
### Review Reports
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [reviews/coverage-analysis-review.md](reviews/coverage-analysis-review.md) | Cross-validation of coverage analysis |
|
||||
| [reviews/doc-quality-review.md](reviews/doc-quality-review.md) | Cross-validation of doc quality analysis |
|
||||
| [reviews/link-coverage-review.md](reviews/link-coverage-review.md) | Cross-validation of link coverage |
|
||||
| [reviews/external-docs-review.md](reviews/external-docs-review.md) | Cross-validation of external project docs |
|
||||
| [reviews/docs-code-mismatch-review.md](reviews/docs-code-mismatch-review.md) | Cross-validation of mismatch analysis |
|
||||
| [reviews/docs-out-of-date-review.md](reviews/docs-out-of-date-review.md) | Cross-validation of out-of-date analysis |
|
||||
| [reviews/docs-link-not-covered-review.md](reviews/docs-link-not-covered-review.md) | Cross-validation of link-not-covered analysis |
|
||||
| [reviews/function-inventory-review.md](reviews/function-inventory-review.md) | Cross-validation of function inventory |
|
||||
| [reviews/coverage-docquality-review.md](reviews/coverage-docquality-review.md) | Second-pass review of coverage + doc quality |
|
||||
| [reviews/mismatch-outofdate-review.md](reviews/mismatch-outofdate-review.md) | Second-pass review of mismatch + out-of-date |
|
||||
| [reviews/link-reports-review.md](reviews/link-reports-review.md) | Second-pass review of link reports |
|
||||
| [reviews/external-index-review.md](reviews/external-index-review.md) | Second-pass review of external docs + index |
|
||||
|
||||
---
|
||||
|
||||
## Key Findings Summary
|
||||
|
||||
### Test Coverage
|
||||
- **Rust**: 312 inline tests + 5 integration tests across 8/10 crates
|
||||
- **Dart**: 221 tests (widgets: 58%, services: 90%)
|
||||
- **Untested crates**: chanora_bridge, chanora_cache, chanora_prefetch
|
||||
|
||||
### Documentation Gaps
|
||||
- No architecture docs for: audio engine, FFI bridge, protocol layer, state machine, cache, prefetch, diagnostics
|
||||
- 15 TODO/FIXME items catalogued across codebase
|
||||
|
||||
### Dead/Useless Code
|
||||
- No true dead code found (platform-gated items are intentional)
|
||||
- 1 malformed markdown in docs/sysdes.md
|
||||
- 2 missing LICENSE files (LICENSE-APACHE, LICENSE-MIT)
|
||||
|
||||
### Duplicated Content
|
||||
- Lifecycle chain repeated in 6+ files
|
||||
- Git commit examples in 3 files
|
||||
- Security doc list in 2 files
|
||||
|
||||
### External Dependencies
|
||||
- **ReSpeak/tsclientlib**: Chanora patches tsproto-types for P-256 coordinate padding
|
||||
- **TeaSpeak**: Compatible voice server, C++20 + Electron architecture
|
||||
- **yat.qa**: TeamSpeak admin tool, v3.9.9b, English + German docs
|
||||
@@ -1,375 +0,0 @@
|
||||
# Test & Document Coverage Analysis
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
**Scope:** All crates, Flutter app, and docs/ directory
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Total Rust tests (inline `#[test]`) | 312 |
|
||||
| Total Rust integration tests | 5 |
|
||||
| Total Dart tests (`test()` + `testWidgets()`) | 221 |
|
||||
| Crates with tests | 7/9 |
|
||||
| Dart services with tests | 19/21 (90%) |
|
||||
| Dart widgets with tests | 14/24 (58%) |
|
||||
| Overall estimated coverage | ~65% |
|
||||
|
||||
---
|
||||
|
||||
## Per-Crate Test Coverage (Rust)
|
||||
|
||||
### chanora_audio — 333 tests, ~48% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| engine.rs | ~45 | 7 | ~16% |
|
||||
| ptt_backends/windows.rs | ~80 | 44 | ~55% |
|
||||
| ptt_backends/windows_keymap.rs | ~30 | 12 | ~40% |
|
||||
| ptt_backends/macos.rs | ~35 | 12 | ~34% |
|
||||
| ptt_backends/linux.rs | ~25 | 10 | ~40% |
|
||||
| ptt_backends/mod.rs | ~15 | 1 | ~7% |
|
||||
| transmit_selector.rs | ~20 | 10 | ~50% |
|
||||
| voice_activity.rs | ~15 | 9 | ~60% |
|
||||
| voice_render.rs | ~15 | 9 | ~60% |
|
||||
| mobile_voice_backend.rs | ~20 | 9 | ~45% |
|
||||
| processor/sonora.rs | ~15 | 8 | ~53% |
|
||||
| processor/dsp/agc2.rs | ~10 | 6 | ~60% |
|
||||
| mode_stack.rs | ~10 | 6 | ~60% |
|
||||
| opus_voice.rs | ~10 | 5 | ~50% |
|
||||
| capture_accumulator.rs | ~8 | 5 | ~63% |
|
||||
| processor/dsp/ns.rs | ~8 | 4 | ~50% |
|
||||
| ptt.rs | ~8 | 4 | ~50% |
|
||||
| vad/mod.rs | ~6 | 4 | ~67% |
|
||||
| audio_processing.rs | ~8 | 3 | ~38% |
|
||||
| debug_wav.rs | ~6 | 3 | ~50% |
|
||||
| processor/dsp/hpf.rs | ~5 | 3 | ~60% |
|
||||
| processor/dsp/aec3.rs | ~5 | 3 | ~60% |
|
||||
| transmit_mode.rs | ~4 | 3 | ~75% |
|
||||
| android_render_ring.rs | ~5 | 3 | ~60% |
|
||||
| vad/resampler.rs | ~4 | 3 | ~75% |
|
||||
| vad/apple_coreml.rs | ~5 | 3 | ~60% |
|
||||
| vad/silero_onnx.rs | ~10 | 6 | ~60% |
|
||||
| render_reference.rs | ~8 | 7 | ~88% |
|
||||
| capture_resampler.rs | ~3 | 2 | ~67% |
|
||||
| audio_event_queue.rs | ~4 | 2 | ~50% |
|
||||
| processor/webrtc_apm.rs | ~5 | 2 | ~40% |
|
||||
| frame.rs | ~3 | 1 | ~33% |
|
||||
| lib.rs (defaults) | ~5 | 1 | ~20% |
|
||||
| release_tail.rs | ~4 | 1 | ~25% |
|
||||
| route_policy.rs | ~8 | 8 | ~100% |
|
||||
| **Integration: tests/ptt_privacy.rs** | — | 1 | — |
|
||||
| **Integration: tests/linux_portal_smoke.rs** | — | 1 | — |
|
||||
|
||||
### chanora_protocol — 17 tests, ~18% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| adapter.rs | ~80 | 13 | ~16% |
|
||||
| poke_limiter.rs | ~17 | 4 | ~24% |
|
||||
| dto.rs | ~0 | 0 | — |
|
||||
| lib.rs | ~0 | 0 | — |
|
||||
|
||||
**Untested areas:** Message parsing, serialization, most adapter methods
|
||||
|
||||
### chanora_state — 27 tests, ~46% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~35 | 18 | ~51% |
|
||||
| channel_join.rs | ~24 | 9 | ~38% |
|
||||
|
||||
### chanora_storage — 15 tests, ~24% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~62 | 15 | ~24% |
|
||||
|
||||
**Untested areas:** Migration logic, concurrent access patterns, error recovery
|
||||
|
||||
### chanora_resolver — 12 tests, ~17% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~70 | 12 | ~17% |
|
||||
| examples/cli.rs | — | 1 | — |
|
||||
|
||||
**Untested areas:** DNS failure modes, timeout handling, cache behavior
|
||||
|
||||
### chanora_diagnostics — 19 tests, ~26% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~74 | 19 | ~26% |
|
||||
|
||||
### chanora_core — 38 tests, ~7% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~140 | 10 | ~7% |
|
||||
| network_diagnostics.rs | ~7 | 1 | ~14% |
|
||||
|
||||
**Untested areas:** Connection lifecycle, server event handling, most state transitions
|
||||
|
||||
### chanora_bridge — 0 tests, 0% function coverage
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| api.rs | ~200+ | 0 | 0% |
|
||||
| frb_generated.rs | ~100+ | 0 | 0% |
|
||||
| permission_jni.rs | ~10 | 0 | 0% |
|
||||
| android_init.rs | ~5 | 0 | 0% |
|
||||
| lib.rs | ~4 | 0 | 0% |
|
||||
|
||||
**Note:** chanora_bridge is an FFI/bridge layer; testing requires integration with Flutter.
|
||||
|
||||
### chanora_cache — 0 tests
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~16 | 0 | 0% |
|
||||
|
||||
### chanora_prefetch — 0 tests
|
||||
|
||||
| Source File | Functions | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| lib.rs | ~18 | 0 | 0% |
|
||||
|
||||
---
|
||||
|
||||
## Per-Module Test Coverage (Dart/Flutter)
|
||||
|
||||
### Services — 155 tests across 19 test files
|
||||
|
||||
| Source File | Test File | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| android_audio_output_devices.dart | ✅ android_audio_output_devices_test.dart | 2 | Tested |
|
||||
| android_permissions_service.dart | ✅ android_permissions_service_test.dart | 14 | Tested |
|
||||
| app_bootstrap.dart | ✅ app_bootstrap_test.dart | 4 | Tested |
|
||||
| audio_lifecycle_service.dart | ✅ audio_lifecycle_service_test.dart | 4 | Tested |
|
||||
| back_intent_policy.dart | ✅ back_intent_policy_test.dart | 9 | Tested |
|
||||
| back_intent_service.dart | ✅ back_intent_service_test.dart | 5 | Tested |
|
||||
| channel_join_error_mapper.dart | ✅ channel_join_error_mapper_test.dart | Tested |
|
||||
| channel_spacer.dart | ✅ channel_spacer_test.dart | 9 | Tested |
|
||||
| connection_phase_state.dart | ✅ connection_phase_state_test.dart | 8 | Tested |
|
||||
| hard_mute_owners.dart | ✅ hard_mute_owners_test.dart | 3 | Tested |
|
||||
| ios_audio_session_controller.dart | ✅ ios_audio_session_controller_test.dart | 6 | Tested |
|
||||
| macos_permissions_service.dart | ✅ macos_permissions_service_test.dart | 20 | Tested |
|
||||
| poke_active_chat.dart | ✅ poke_active_chat_test.dart | 2 | Tested |
|
||||
| poke_notification_service.dart | ✅ poke_notification_service_test.dart | 2 | Tested |
|
||||
| poke_preferences_service.dart | ✅ poke_preferences_service_test.dart | 4 | Tested |
|
||||
| prefetch_debouncer.dart | ✅ prefetch_debouncer_test.dart | 3 | Tested |
|
||||
| snapshot_state_mapper.dart | ✅ snapshot_state_mapper_test.dart | 7 | Tested |
|
||||
| ts3_server_link.dart | ✅ ts3_server_link_test.dart | 3 | Tested |
|
||||
| ui_preferences_service.dart | ✅ ui_preferences_service_test.dart | 5 | Tested |
|
||||
| voice_join_ordering.dart | ✅ voice_join_ordering_test.dart | 4 | Tested |
|
||||
| **ios_permissions_service.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **link_trust_service.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
|
||||
### Widgets — 66 tests across 13 test files
|
||||
|
||||
| Source File | Test File | Tests | Coverage |
|
||||
|-------------|-----------|-------|----------|
|
||||
| app_snack_bar.dart | ✅ app_snack_bar_test.dart | 1 | Tested |
|
||||
| audio_processing_config_state.dart | ✅ audio_processing_config_state_test.dart | 8 | Tested |
|
||||
| bbcode_text.dart | ✅ bbcode_text_test.dart | Tested |
|
||||
| chat_panel.dart | ✅ chat_panel_test.dart | Tested |
|
||||
| chat_views.dart | ✅ chat_views_test.dart | 29 | Tested |
|
||||
| client_info_sheet.dart | ✅ client_info_sheet_test.dart | Tested |
|
||||
| poke_notification_settings.dart | ✅ poke_notification_settings_test.dart | Tested |
|
||||
| snapshot_view.dart | ✅ snapshot_view_test.dart | Tested |
|
||||
| talk_power_warning.dart | ✅ talk_power_warning_test.dart | 1 | Tested |
|
||||
| voice_compact.dart | ✅ voice_compact_test.dart | Tested |
|
||||
| voice_settings_controls.dart | ✅ voice_settings_controls_test.dart | 6 | Tested |
|
||||
| voice_status_summary.dart | ✅ voice_status_summary_test.dart | 5 | Tested |
|
||||
| mobile_ui_resilience.dart | ✅ mobile_ui_resilience_test.dart | Tested |
|
||||
| **audio_debug_stats_panel.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **audio_device_list_tile.dart** | ✅ audio_device_list_tile_test.dart | 3 | Tested |
|
||||
| **audio_output_tile.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **connect_widgets.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **input_dialogs.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **permission_state_banner.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **ptt_capability_badge.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **voice_bar.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **voice_haptics.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **voice_level_meter.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **voice_platform.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
| **voice_settings.dart** | ❌ No test file | 0 | **UNTESTED** |
|
||||
|
||||
### E2E Tests
|
||||
|
||||
| File | Tests | Notes |
|
||||
|------|-------|-------|
|
||||
| alpha_e2e_test.dart | 1 | End-to-end integration |
|
||||
| beta_e2e_test.dart | 1 | End-to-end integration |
|
||||
| widget_test.dart | — | Default Flutter template |
|
||||
|
||||
---
|
||||
|
||||
## Document Coverage Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Total doc files (under docs/) | 66 |
|
||||
| Modules documented | ~15 areas |
|
||||
| Estimated outdated docs | 3-5 |
|
||||
|
||||
## Document Inventory
|
||||
|
||||
### Architecture (4 files)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| architecture/sad.md | Software Architecture Document | Current | 190 lines, references SDD |
|
||||
| architecture/sdd.md | Software Design Document | Current | 161 lines |
|
||||
| architecture/sysdes.md | System Design overview | Current | 21 lines, brief |
|
||||
| architecture/desktop-ptt-architecture.md | Desktop PTT subsystem design | Current | 43 lines |
|
||||
| architecture/file-transfer-design.md | File transfer feature design | Current | 737 lines |
|
||||
| architecture/file-transfer-research.md | File transfer research | Current | 770 lines |
|
||||
| architecture/file-transfer-implementation-plan.md | File transfer implementation plan | Current | 1315 lines |
|
||||
|
||||
### Requirements (2 files + 2 symlinks)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| requirements/srs.md | Software Requirements Spec | Current | 22 lines (pointer) |
|
||||
| requirements/sysrs.md | System Requirements Spec | Current | 22 lines (pointer) |
|
||||
| srs.md | SRS (full) | Current | 2850 lines |
|
||||
| sysrs.md | SysRS (full) | Current | 2014 lines |
|
||||
|
||||
### Verification (5 files)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| verification/verification-master-plan.md | Overall V&V plan | Current | 91 lines |
|
||||
| verification/swe4-unit-verification-plan.md | Unit test plan | Current | 60 lines |
|
||||
| verification/swe5-software-integration-verification-plan.md | Integration test plan | Current | 71 lines |
|
||||
| verification/swe6-software-verification-plan.md | System verification plan | Current | 62 lines |
|
||||
| verification/sys4-system-integration-verification-plan.md | System integration plan | Current | 58 lines |
|
||||
|
||||
### Security (7 files)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| security/threat-model.md | Threat model | Current | 33 lines |
|
||||
| security/license-inventory.md | Rust license inventory | Current | 10970 lines |
|
||||
| security/flutter-license-inventory.md | Flutter license inventory | Current | 5477 lines |
|
||||
| security/diagnostic-redaction-audit-report.md | Diagnostic redaction audit | Current | 31 lines |
|
||||
| security/secure-storage-audit-report.md | Secure storage audit | Current | 31 lines |
|
||||
| security/dependency-and-supply-chain-report.md | Dependency audit | Current | 43 lines |
|
||||
| security/security-privacy-legal-guideline.md | Security/privacy guidelines | Current | 63 lines |
|
||||
|
||||
### Governance (11 files)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| governance/document-index.md | Document catalog | Current | 36 lines |
|
||||
| governance/document-naming-convention.md | Naming conventions | Current | 33 lines |
|
||||
| governance/document-review-report.md | Review report | Current | 37 lines |
|
||||
| governance/traceability-matrix.md | Requirements traceability | Current | 73 lines |
|
||||
| governance/product-decision-register.md | Decision log | Current | 25 lines |
|
||||
| governance/decision-impact-assessment.md | Impact assessment | Current | 18 lines |
|
||||
| governance/git-commit-message-convention.md | Commit conventions | Current | 21 lines |
|
||||
| governance/path-migration-map.md | Path migration plan | Current | 18 lines |
|
||||
| governance/repo-format-validation-report.md | Format validation | Current | 22 lines |
|
||||
| governance/baseline-candidate-validation-report.md | Baseline validation | Current | 38 lines |
|
||||
| governance/baseline-approval-record.md | Baseline approval | Current | 32 lines |
|
||||
| governance/maintainability-review-2026-06-08.md | Maintainability review | Current | 99 lines |
|
||||
|
||||
### Release (4 files)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| release/ios-build.md | iOS build instructions | Current | 47 lines |
|
||||
| release/platform-release-policy.md | Release policy | Current | 26 lines |
|
||||
| release/release-readiness-go-nogo-record.md | Go/no-go record | Current | 105 lines |
|
||||
| release/dv-waiver-register.md | DV waiver register | Current | 36 lines |
|
||||
|
||||
### UI/UX (4 files)
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| ui-ux/material3-guideline.md | Material 3 guidelines | Current | 8 lines (brief) |
|
||||
| ui-ux/material3-design-tokens.md | Design tokens | Current | 21 lines |
|
||||
| ui-ux/material3-component-catalog.md | Component catalog | Current | 38 lines |
|
||||
| ui-ux/adaptive-layout-platform-guide.md | Adaptive layout guide | Current | 27 lines |
|
||||
|
||||
### Other
|
||||
|
||||
| File | Topic | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| privacy/privacy-policy.md | Privacy policy | Current | 51 lines |
|
||||
| references/external-references.md | External references | Current | 21 lines |
|
||||
| references/aspice-swe2-swe3-integration-note.md | ASPICE integration note | Current | 28 lines |
|
||||
| legal/trademark-and-attribution-review.md | Trademark review | Current | 47 lines |
|
||||
| i18n/localization-architecture.md | Localization architecture | Current | 44 lines |
|
||||
| implementation-status-2026-05-28.md | Implementation status | **Possibly outdated** | Date is 2026-05-28 |
|
||||
| material3-guideline.md | Material 3 guideline (duplicate) | Current | 93 lines |
|
||||
|
||||
### Superpowers Plans & Specs (13 files)
|
||||
|
||||
| File | Topic | Status |
|
||||
|------|-------|--------|
|
||||
| superpowers/plans/2026-05-28-server-resolution-prefetch.md | Server resolution prefetch plan | Current |
|
||||
| superpowers/plans/2026-05-28-chanora-server-prefetch-crate.md | Prefetch crate plan | Current |
|
||||
| superpowers/plans/2026-05-29-dv-evidence-pack.md | DV evidence pack plan | Current |
|
||||
| superpowers/plans/2026-05-29-state-sync-ui-settings-validation.md | State sync validation plan | Current |
|
||||
| superpowers/plans/2026-05-29-swe2-swe3-baselines.md | SWE2/SWE3 baselines plan | Current |
|
||||
| superpowers/plans/2026-05-29-finish-dv-document-tree.md | DV document tree plan | Current |
|
||||
| superpowers/plans/2026-06-06-chat-panel-switching.md | Chat panel switching plan | Current |
|
||||
| superpowers/plans/2026-06-08-maintainability-continuation.md | Maintainability continuation | Current |
|
||||
| superpowers/plans/2026-06-08-core-internal-split.md | Core internal split plan | Current |
|
||||
| superpowers/specs/2026-05-28-server-resolution-prefetch-design.md | Prefetch design spec | Current |
|
||||
| superpowers/specs/2026-05-28-chanora-server-prefetch-crate-design.md | Prefetch crate design | Current |
|
||||
| superpowers/specs/2026-05-29-state-sync-ui-settings-validation-design.md | State sync design | Current |
|
||||
| superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | Adaptive layout design | Current |
|
||||
| superpowers/specs/2026-06-08-maintainability-continuation-design.md | Maintainability design | Current |
|
||||
| superpowers/specs/2026-06-09-poke-without-message-design.md | Poke without message design | Current |
|
||||
|
||||
---
|
||||
|
||||
## Documentation Gaps
|
||||
|
||||
The following code modules have **no dedicated documentation**:
|
||||
|
||||
| Module | Functions | Gap Description |
|
||||
|--------|-----------|-----------------|
|
||||
| `chanora_audio` (engine) | ~45 | No architecture doc for audio engine internals |
|
||||
| `chanora_audio` (VAD subsystem) | ~25 | VAD pipeline, model loading, fallback strategy undocumented |
|
||||
| `chanora_audio` (DSP processors) | ~30 | AEC3, AGC2, NS, HPF configuration undocumented |
|
||||
| `chanora_audio` (PTT backends) | ~155 | Platform-specific PTT behavior undocumented |
|
||||
| `chanora_bridge` (FFI layer) | ~300 | Flutter-Rust bridge API contract undocumented |
|
||||
| `chanora_cache` | ~16 | Cache strategy, eviction policy undocumented |
|
||||
| `chanora_prefetch` | ~18 | Prefetch timing, debouncing strategy undocumented |
|
||||
| `chanora_core` | ~147 | Core connection lifecycle, event handling undocumented |
|
||||
| `chanora_state` | ~59 | State machine transitions, delta emission undocumented |
|
||||
| `chanora_storage` | ~62 | Storage format, migration strategy undocumented |
|
||||
| `chanora_protocol` | ~97 | Protocol message format, adapter logic undocumented |
|
||||
| `chanora_resolver` | ~70 | DNS resolution, TSDNS discovery undocumented |
|
||||
| `chanora_diagnostics` | ~74 | Diagnostic collection, redaction rules undocumented |
|
||||
| Flutter services layer | ~21 files | No service-layer architecture doc |
|
||||
| Flutter widgets layer | ~24 files | No widget catalog or component doc |
|
||||
| Localization (l10n) | — | Translation workflow undocumented (only architecture doc exists) |
|
||||
|
||||
## Potentially Outdated Documents
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `implementation-status-2026-05-28.md` | Dated 2026-05-28; code has changed significantly since |
|
||||
| `docs/material3-guideline.md` | Duplicate of `docs/ui-ux/material3-guideline.md` |
|
||||
| `docs/sysdes.md` | Top-level duplicate of `docs/architecture/sysdes.md` |
|
||||
| `docs/srs.md` / `docs/sysrs.md` | Top-level duplicates of `docs/requirements/` versions |
|
||||
|
||||
---
|
||||
|
||||
## Key Findings
|
||||
|
||||
1. **chanora_audio** is the best-tested crate (204 tests), but still only ~48% function coverage due to the large codebase (~428 functions)
|
||||
2. **chanora_bridge**, **chanora_cache**, and **chanora_prefetch** have zero tests
|
||||
3. **chanora_core** has very low coverage (~7%) despite being the main connection orchestrator
|
||||
4. **Dart widget tests** cover only 54% of widget files; 11 widget files have no tests
|
||||
5. **Dart service tests** are strong at 90% coverage (only 2 files untested)
|
||||
6. **Documentation** is extensive (55 files) but focuses on process/governance; code-level architecture docs are sparse
|
||||
7. No dedicated docs exist for the audio engine, FFI bridge, protocol layer, or state machine internals
|
||||
@@ -1,123 +0,0 @@
|
||||
# Documentation Quality Analysis
|
||||
|
||||
## Summary
|
||||
- Total docs analyzed: 64
|
||||
- Duplicated content instances: 8
|
||||
- Path record files (DV navigation aids): 4
|
||||
- Genuine issues (malformed markdown): 1
|
||||
- Broken references: 1 (suggested file names only; SDD-109/SAD-043 are valid historical refs)
|
||||
|
||||
## Duplicated Content
|
||||
|
||||
### Instance 1: Lifecycle Documentation Chain
|
||||
- **Files**: `README.md:280`, `CONTRIBUTING.md:10`, `docs/sysdes.md:90`, `docs/sysrs.md:108`, `docs/governance/traceability-matrix.md:16`, `docs/references/aspice-swe2-swe3-integration-note.md:12`
|
||||
- **Content**: `SysRS -> SysDes -> SRS -> SAD -> SDD` lifecycle chain repeated across 6+ files
|
||||
- **Recommendation**: Define once in `README.md` and reference from other docs
|
||||
|
||||
### Instance 2: Git Commit Convention Examples
|
||||
- **Files**: `README.md:380`, `CONTRIBUTING.md:38`, `docs/governance/git-commit-message-convention.md:15`
|
||||
- **Content**: Same commit examples (`feat(voice): add push-to-talk state handling`, `fix(protocol): recover channel tree after reconnect snapshot`, etc.) duplicated across 3 files
|
||||
- **Recommendation**: Keep examples only in `docs/governance/git-commit-message-convention.md` and reference from README/CONTRIBUTING
|
||||
|
||||
### Instance 3: Security/Privacy/Legal Document List
|
||||
- **Files**: `README.md:349-355`, `SECURITY.md:33-38`
|
||||
- **Content**: Same list of 6 security documents (threat-model, secure-storage, diagnostic-redaction, dependency, privacy-policy, trademark) repeated verbatim
|
||||
- **Recommendation**: Keep list in `SECURITY.md` and reference from README
|
||||
|
||||
### Instance 4: Architecture Component Table
|
||||
- **Files**: `README.md:73-92`, `docs/architecture/sad.md:56-67`
|
||||
- **Content**: Similar architecture overview showing Flutter UI, Rust Core, Protocol Layer structure
|
||||
- **Recommendation**: Keep detailed version in SAD; use abbreviated version in README
|
||||
|
||||
### Instance 5: Platform Policy Table
|
||||
- **Files**: `README.md:47-54`, `docs/release/platform-release-policy.md:12-19`
|
||||
- **Content**: Platform requirements table with overlapping information
|
||||
- **Recommendation**: Consolidate in `platform-release-policy.md` and reference from README
|
||||
|
||||
### Instance 6: Security Gate Requirements
|
||||
- **Files**: `docs/security/security-privacy-legal-guideline.md:13-21`, `docs/security/threat-model.md:22-30`
|
||||
- **Content**: Similar threat/mitigation tables with overlapping secure-storage and diagnostics concerns
|
||||
- **Recommendation**: Threat model should reference the guideline for gate requirements
|
||||
|
||||
### Instance 7: DV Conclusion Pattern
|
||||
- **Files**: Nearly every `docs/` file ends with a "## DV Conclusion" section
|
||||
- **Content**: Repetitive pattern: "[Area] is documented for DV. [Limitation] remains."
|
||||
- **Recommendation**: This is intentional for ASPICE compliance. No change needed, but consider a template.
|
||||
|
||||
### Instance 8: Android Runtime Gate Documentation
|
||||
- **Files**: `docs/verification/swe5-software-integration-verification-plan.md:57-68`, `docs/governance/maintainability-review-2026-06-08.md:61-88`
|
||||
- **Content**: Same Android ADB/emulator verification steps and `adb devices -l` requirements
|
||||
- **Recommendation**: Define once in a shared reference and import
|
||||
|
||||
## Path Record Files (DV Navigation Aids)
|
||||
|
||||
These files are intentional ASPICE DV entry-point records with reviewer navigation tables. They are NOT useless — they serve a specific compliance purpose. Listed here for awareness only.
|
||||
|
||||
### DV Navigation Aids
|
||||
|
||||
| File | Line | Header | Issue |
|
||||
|------|------|--------|-------|
|
||||
| `docs/architecture/sysdes.md` | 1-21 | Entire file | Path record — points to `docs/sysdes.md` for DV reviewer navigation |
|
||||
| `docs/requirements/sysrs.md` | 1-22 | Entire file | Path record — points to `docs/sysrs.md` for DV reviewer navigation |
|
||||
| `docs/requirements/srs.md` | 1-22 | Entire file | Path record — points to `docs/srs.md` for DV reviewer navigation |
|
||||
| `docs/ui-ux/material3-guideline.md` | 1-8 | Entire file | Path record — points to `docs/material3-guideline.md` for DV reviewer navigation |
|
||||
|
||||
### Genuine Issues
|
||||
|
||||
| File | Line | Header | Issue |
|
||||
|------|------|--------|-------|
|
||||
| `docs/sysdes.md` | 13 | `**Repo path:** ... ---` | Malformed markdown (missing blank line before `---`) |
|
||||
|
||||
### TODO/Placeholder Markers
|
||||
|
||||
No actual TODO/TBD/placeholder markers found in the documentation files. The codebase is clean of such markers.
|
||||
|
||||
### Broken References
|
||||
|
||||
| File | Line | Reference | Issue |
|
||||
|------|------|-----------|-------|
|
||||
| `docs/sysrs.md` | 126-130 | `docs/chanora_SysDes.md`, `docs/chanora_SRS.md`, etc. | These suggested file names do not exist. Actual files use different names (`docs/sysdes.md`, `docs/srs.md`, etc.) |
|
||||
| `docs/implementation-status-2026-05-28.md` | 103 | `SDD-109` | References a specific SDD item ID that is not itemized in the current SDD baseline |
|
||||
| `docs/implementation-status-2026-05-28.md` | 105 | `SAD-043` | References a specific SAD item ID that is not itemized in the current SAD baseline |
|
||||
|
||||
### Outdated Content
|
||||
|
||||
| File | Line | Content | Issue |
|
||||
|------|------|---------|-------|
|
||||
| `docs/sysdes.md` | 6 | Version `0.9.8` | Superseded by later governance docs dated 2026-05-29 |
|
||||
| `docs/sysrs.md` | 5 | Version `0.9.11` | May need alignment with SysDes version |
|
||||
| `docs/material3-guideline.md` | 4-5 | Version `0.9.2` | Change history stops at 2026-05-14; no updates for 2026-05-29 baseline |
|
||||
| `tools/windows-smoke.md` | 6 | `product/scaffold-v0` branch | Default base branch changed to `main` per CHANGELOG |
|
||||
| `docs/implementation-status-2026-05-28.md` | 140 | Agent spec docs reference | States docs are "deleted from the working tree but still in git HEAD" — stale cleanup note |
|
||||
|
||||
### Stale Content
|
||||
|
||||
| File | Line | Content | Issue |
|
||||
|------|------|---------|-------|
|
||||
| `docs/implementation-status-2026-05-28.md` | 1 | Date: 2026-05-28 | Pre-dates DV baseline (2026-05-29); may not reflect final baseline state |
|
||||
| `docs/governance/git-commit-message-convention.md` | 18 | `release(android): prepare internal alpha build metadata` | Example uses `release` type which is not in the Conventional Commits standard types |
|
||||
|
||||
## Duplicated Code Blocks
|
||||
|
||||
| Code Hash | Files | Description |
|
||||
|-----------|-------|-------------|
|
||||
| Lifecycle chain | `README.md:280`, `CONTRIBUTING.md:10`, `docs/sysdes.md:90`, `docs/sysrs.md:108`, `docs/governance/traceability-matrix.md:16`, `docs/references/aspice-swe2-swe3-integration-note.md:12` | `SysRS -> SysDes -> SRS -> SAD -> SDD -> Verification` |
|
||||
| Commit examples | `README.md:379-386`, `CONTRIBUTING.md:37-42`, `docs/governance/git-commit-message-convention.md:14-19` | Overlapping commit message examples (different subsets in each file) |
|
||||
| Security doc list | `README.md:349-355`, `SECURITY.md:33-38` | 6 identical file paths |
|
||||
| Architecture ASCII art | `README.md:73-92`, `docs/architecture/sad.md:56-67` | Similar but not identical architecture diagrams |
|
||||
| Platform table | `README.md:47-54`, `docs/release/platform-release-policy.md:12-19` | Overlapping platform requirement tables |
|
||||
|
||||
## Recommendations
|
||||
|
||||
### High Priority
|
||||
1. **Consolidate lifecycle chain**: Define once in README, reference elsewhere
|
||||
2. **Fix suggested file names**: `docs/sysrs.md` lines 126-130 reference non-existent file names
|
||||
|
||||
### Medium Priority
|
||||
4. **Consolidate commit examples**: Keep in `git-commit-message-convention.md` only
|
||||
5. **Consolidate security doc list**: Keep in `SECURITY.md` only
|
||||
6. **Update outdated branch reference**: `tools/windows-smoke.md` references `product/scaffold-v0` but default is now `main`
|
||||
|
||||
### Low Priority
|
||||
7. **Align document versions**: SysDes (0.9.8), SysRS (0.9.11), Material3 (0.9.2) have different versions
|
||||
8. **Clean up implementation status**: Remove stale agent-spec references and update date
|
||||
@@ -1,458 +0,0 @@
|
||||
# Documentation-Code Mismatch Analysis
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
|
||||
## Summary
|
||||
- Total claims verified: ~150
|
||||
- Mismatches found: 17
|
||||
- Critical: 2 | Major: 4 | Minor: 11
|
||||
|
||||
## Critical Mismatches (wrong API / broken reference)
|
||||
|
||||
### 1. [README.md:428-431] - LICENSE files referenced but do not exist
|
||||
- **Doc claims:** Links to `LICENSE-APACHE` and `LICENSE-MIT` at repository root
|
||||
- **Code shows:** Neither `LICENSE-APACHE` nor `LICENSE-MIT` exists at `/Users/edison/dev/chanora/`
|
||||
- **Impact:** Users clicking license links in README get 404 on GitHub. Dual-license model (DEC-020) requires these files for proper attribution. Also affects `docs/security/license-inventory.md:9-10` and `docs/security/flutter-license-inventory.md:11-12`.
|
||||
|
||||
### 2. [README.md:236-249] - Repository layout missing 3 crates
|
||||
- **Doc claims:** Lists 7 crates: `chanora_protocol`, `chanora_audio`, `chanora_state`, `chanora_storage`, `chanora_diagnostics`, `chanora_bridge` plus `core/chanora_core`
|
||||
- **Code shows:** Actual workspace has 10 crates: adds `chanora_resolver`, `chanora_prefetch`, `chanora_cache` (all present in `Cargo.toml` workspace members and `crates/` directory)
|
||||
- **Impact:** Developers reading README cannot discover 3 existing crates. Resolver, prefetch, and cache functionality is undocumented in the primary entry point.
|
||||
|
||||
## Major Mismatches (wrong behavior / wrong structure)
|
||||
|
||||
### 3. [docs/architecture/sad.md:39-52] - SAD component table missing chanora_cache
|
||||
- **Doc claims:** Component table lists 12 components (Flutter app shell through Server prefetch)
|
||||
- **Code shows:** `chanora_cache` crate exists in workspace (`Cargo.toml:34`) and `crates/chanora_cache/` but is not listed in SAD component architecture
|
||||
- **Impact:** Architecture description incomplete; cache layer is invisible to DV reviewers
|
||||
|
||||
### 4. [docs/architecture/sdd.md:19] - snapshot_state_mapper.dart listed under wrong component
|
||||
- **Doc claims:** `SDD-MOD-003 Snapshot and channel UI` lists `snapshot_state_mapper.dart` as a widget-layer file
|
||||
- **Code shows:** `snapshot_state_mapper.dart` is in `apps/chanora_flutter/lib/services/`, not `apps/chanora_flutter/lib/widgets/`
|
||||
- **Impact:** Minor categorization issue — SDD header says "widget/service layer" but the module table groups it under widgets. Also affects `channel_spacer.dart` (same row).
|
||||
|
||||
### 5. [tools/windows-smoke.md:6] - Branch reference outdated
|
||||
- **Doc claims:** Script designed for `product/scaffold-v0` branch
|
||||
- **Code shows:** Default base branch is `main` per CHANGELOG v0.3.0 line 99
|
||||
- **Impact:** Windows smoke procedure references obsolete branch name
|
||||
|
||||
### 6. [docs/sysrs.md:126-130] - Suggested downstream file names do not exist
|
||||
- **Doc claims:** Lists potential downstream file names: `docs/chanora_SysDes.md`, `docs/chanora_SRS.md`, `docs/chanora_SAD.md`, `docs/chanora_SDD.md`, `docs/chanora_Verification.md`
|
||||
- **Code shows:** Actual files use different names: `docs/sysdes.md`, `docs/srs.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/verification/verification-master-plan.md`
|
||||
- **Impact:** Aspirational/historical names mislead readers about actual file locations
|
||||
|
||||
### 7. [docs/governance/product-decision-register.md:18] - DEC-030 VoiceActivity scope partially superseded
|
||||
- **Doc claims:** DEC-030 is "Partially superseded by desktop enablement"
|
||||
- **Code shows:** `voice_activity.rs` exists with `VoiceActivityStateMachine`; `transmit_mode.rs` has `TransmitMode::VoiceActivity`; VAD backends exist in `vad/` directory. Windows/Linux desktop VAD is implemented via capture path.
|
||||
- **Impact:** Decision register does not fully reflect current implementation state; desktop VAD is more complete than "partially superseded" suggests
|
||||
|
||||
## Minor Mismatches (cosmetic / slight drift)
|
||||
|
||||
### 8. [README.md:17] - Status description slightly outdated
|
||||
- **Doc claims:** "Chanora is currently a baseline-candidate Flutter + Rust workspace"
|
||||
- **Code shows:** Workspace version is `0.2.0-beta.1`, Flutter app is `0.3.0+100`; project has working voice, chat, bookmarks, diagnostics
|
||||
- **Impact:** "baseline-candidate" undersells current implementation maturity
|
||||
|
||||
### 9. [docs/material3-guideline.md:10] - Self-referencing path record
|
||||
- **Doc claims:** `**Repo path:** docs/ui-ux/material3-guideline.md`
|
||||
- **Code shows:** This file IS at `docs/material3-guideline.md`, not `docs/ui-ux/material3-guideline.md`
|
||||
- **Impact:** Path record creates circular reference confusion
|
||||
|
||||
### 10. [docs/implementation-status-2026-05-28.md:1] - Status date pre-dates DV baseline
|
||||
- **Doc claims:** Date 2026-05-28
|
||||
- **Code shows:** DV baseline documents are dated 2026-05-29; code has changed significantly since
|
||||
- **Impact:** Implementation status may not reflect final baseline state
|
||||
|
||||
### 11. [docs/implementation-status-2026-05-28.md:103,105] - References to non-itemized SDD/SAD IDs
|
||||
- **Doc claims:** References `SDD-109` and `SAD-043`
|
||||
- **Code shows:** Current SAD/SDD baselines do not use itemized ID numbering
|
||||
- **Impact:** Historical references cannot be traced in current baseline
|
||||
|
||||
### 12. [docs/governance/git-commit-message-convention.md:18] - Non-standard commit type
|
||||
- **Doc claims:** Example uses `release(android): prepare internal alpha build metadata`
|
||||
- **Code shows:** `release` is not a standard Conventional Commits type
|
||||
- **Impact:** Minor convention inconsistency
|
||||
|
||||
### 13. [docs/ui-ux/material3-guideline.md:4-5] - Version history stops at 0.9.2
|
||||
- **Doc claims:** Version 0.9.2, last updated 2026-05-14
|
||||
- **Code shows:** DV baseline documents dated 2026-05-29; no update for baseline
|
||||
- **Impact:** Material 3 guideline may not reflect latest baseline decisions
|
||||
|
||||
### 14. [docs/sysdes.md:6] - SysDes version older than SysRS
|
||||
- **Doc claims:** SysDes version 0.9.8
|
||||
- **Code shows:** SysRS version 0.9.11
|
||||
- **Impact:** Version numbering inconsistency between related documents
|
||||
|
||||
### 15. [docs/offline-knowledge/README.md:54] - Claims 2 missing LICENSE files
|
||||
- **Doc claims:** "2 missing LICENSE files (LICENSE-APACHE, LICENSE-MIT)"
|
||||
- **Code shows:** Confirmed - files do not exist at repo root
|
||||
- **Impact:** Consistent finding, but offline-knowledge doc correctly identifies the issue
|
||||
|
||||
### 16. [docs/security/dependency-and-supply-chain-report.md:35] - License inventory location uncertainty
|
||||
- **Doc claims:** `docs/security/license-inventory.md` and Flutter inventory referenced by CI
|
||||
- **Code shows:** Both files exist at `docs/security/license-inventory.md` and `docs/security/flutter-license-inventory.md`
|
||||
- **Impact:** Report expresses uncertainty but files actually exist
|
||||
|
||||
### 17. [docs/architecture/file-transfer-design.md:6] - References SAD-067 which is not itemized
|
||||
- **Doc claims:** "Direct upstream source: docs/architecture/sad.md (SAD-067, SDD-MOD-009)"
|
||||
- **Code shows:** Current SAD baseline does not use itemized SAD-XXX numbering
|
||||
- **Impact:** Historical reference cannot be traced
|
||||
|
||||
## Per-File Verification Results
|
||||
|
||||
### README.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 3 | Cross-platform voice client for TeamSpeak-compatible servers | ✅ PASS | Matches project description |
|
||||
| 8 | Flutter UI + Rust Core + tsclientlib | ✅ PASS | Architecture confirmed |
|
||||
| 17 | Baseline-candidate Flutter + Rust workspace | ⚠️ MINOR | Undersells current maturity |
|
||||
| 47-54 | Platform policy table | ✅ PASS | Matches `docs/release/platform-release-policy.md` |
|
||||
| 57-65 | silero-coreml sibling package | ✅ PASS | Confirmed in workspace layout |
|
||||
| 73-92 | Architecture overview diagram | ✅ PASS | Matches SAD component structure |
|
||||
| 110-123 | MVP Direction table | ✅ PASS | Matches implementation status |
|
||||
| 127-166 | Desktop PTT section | ✅ PASS | Matches `docs/architecture/desktop-ptt-architecture.md` |
|
||||
| 171-231 | Repository Layout (docs/) | ✅ PASS | All listed paths exist |
|
||||
| 236-249 | Repository Layout (implementation) | ❌ FAIL | Missing 3 crates: resolver, prefetch, cache |
|
||||
| 260-271 | Documentation Entry Points | ✅ PASS | All listed paths exist |
|
||||
| 349-355 | Security/Privacy/Legal Gates | ✅ PASS | All listed paths exist |
|
||||
| 400-406 | Development commands | ✅ PASS | Standard Flutter/Cargo commands |
|
||||
| 425-436 | License section | ❌ FAIL | LICENSE-APACHE and LICENSE-MIT do not exist |
|
||||
|
||||
### docs/architecture/sad.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 17 | Rust owns connection orchestration, protocol isolation, audio processing, storage coordination, diagnostics, server resolution, prefetch policy, and bridge DTOs | ✅ PASS | Matches crate responsibilities |
|
||||
| 39-52 | Component architecture table | ⚠️ MAJOR | Missing chanora_cache |
|
||||
| 56-67 | Static architecture view | ✅ PASS | Matches actual dependency flow |
|
||||
| 75-107 | Runtime flow diagrams | ✅ PASS | Connect, voice, diagnostics flows match |
|
||||
| 119-130 | Interface catalogue | ✅ PASS | Matches bridge/protocol boundaries |
|
||||
|
||||
### docs/architecture/sdd.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 15-31 | Module catalogue | ⚠️ MAJOR | snapshot_state_mapper.dart misclassified |
|
||||
| 17 | SDD-MOD-001: `app_bootstrap.dart`, `main.dart` | ✅ PASS | Files exist in services/ and root |
|
||||
| 18 | SDD-MOD-002: `connect_widgets.dart` | ✅ PASS | File exists in widgets/ |
|
||||
| 19 | SDD-MOD-003: `snapshot_view.dart`, `snapshot_state_mapper.dart`, `channel_spacer.dart` | ⚠️ MAJOR | snapshot_state_mapper.dart is in services/ not widgets/ |
|
||||
| 20 | SDD-MOD-004: `chat_views.dart`, `bbcode_text.dart` | ✅ PASS | Files exist in widgets/ |
|
||||
| 21 | SDD-MOD-005: `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | ✅ PASS | All files exist in widgets/ |
|
||||
| 22 | SDD-MOD-006: `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | ✅ PASS | All files exist in services/ |
|
||||
| 23 | SDD-MOD-007: `crates/chanora_bridge/src/api.rs` | ✅ PASS | File exists |
|
||||
| 24 | SDD-MOD-008: `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | ✅ PASS | All files exist |
|
||||
| 25 | SDD-MOD-009: `crates/chanora_protocol/src/` | ✅ PASS | Directory exists |
|
||||
| 26 | SDD-MOD-010: `crates/chanora_state/src/lib.rs`, `channel_join.rs` | ✅ PASS | Both files exist |
|
||||
| 27 | SDD-MOD-011: `crates/chanora_audio/src/` | ✅ PASS | Directory exists with 26 files |
|
||||
| 28 | SDD-MOD-012: `crates/chanora_storage/src/lib.rs` | ✅ PASS | File exists |
|
||||
| 29 | SDD-MOD-013: `crates/chanora_diagnostics/src/lib.rs` | ✅ PASS | File exists |
|
||||
| 30 | SDD-MOD-014: `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` | ✅ PASS | All files exist |
|
||||
| 31 | SDD-MOD-015: `.github/workflows/`, `tools/` | ✅ PASS | Both directories exist |
|
||||
|
||||
### docs/sysdes.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 6 | Version 0.9.8 | ⚠️ MINOR | SysRS is 0.9.11 |
|
||||
| 13 | `**Repo path:** docs/architecture/sysdes.md` | ⚠️ MINOR | Malformed markdown (missing blank line before `---`) |
|
||||
| 377-418 | System elements SE-01 through SE-19 | ✅ PASS | Comprehensive element list |
|
||||
| 839-855 | Interface catalogue IF-001 through IF-014 | ✅ PASS | Matches architecture |
|
||||
|
||||
### docs/sysrs.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 5 | Version 0.9.11 | ✅ PASS | Consistent within document |
|
||||
| 126-130 | Suggested downstream file names | ❌ FAIL | 5 non-existent file names |
|
||||
| 233-257 | Application component requirements SysRS-024 through SysRS-034 | ✅ PASS | Match SAD component allocation |
|
||||
|
||||
### docs/srs.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 6 | Version 0.9.9 | ✅ PASS | Consistent within document |
|
||||
| 101-176 | SWE.1 process requirements SRS-001 through SRS-007 | ✅ PASS | Match ASPICE alignment |
|
||||
| 180-267 | Software boundary requirements SRS-008 through SRS-015 | ✅ PASS | Match architecture constraints |
|
||||
|
||||
### CONTRIBUTING.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 10 | Engineering hierarchy: SysRS -> SysDes -> SRS -> SAD -> SDD | ✅ PASS | Matches README and governance docs |
|
||||
| 25 | Commit convention reference | ✅ PASS | `docs/governance/git-commit-message-convention.md` exists |
|
||||
| 37-42 | Commit examples | ✅ PASS | Match README examples |
|
||||
|
||||
### CHANGELOG.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 7 | v0.3.0 milestone | ✅ PASS | Matches pubspec.yaml version |
|
||||
| 65-66 | Flutter app version/build bumped to 0.3.0+100 | ✅ PASS | Matches pubspec.yaml |
|
||||
| 99 | Default base branch is main | ✅ PASS | Confirms branch change |
|
||||
| 100-101 | DSP chain not yet production-tuned | ✅ PASS | Matches implementation status |
|
||||
|
||||
### docs/architecture/desktop-ptt-architecture.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 17-21 | Platform backends table | ✅ PASS | Matches README PTT section |
|
||||
| 24-30 | Safety rules | ✅ PASS | Watchdog, capability, fallback |
|
||||
|
||||
### docs/architecture/file-transfer-design.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 6 | References SAD-067, SDD-MOD-009 | ⚠️ MINOR | SAD-067 not itemized in current baseline |
|
||||
| 113-137 | tsclientlib public API signatures | ⚠️ MINOR | Cannot verify against external library source |
|
||||
| 400-428 | Avatar path computation in adapter.rs | ✅ PASS | `uid_to_avatar_path` function described |
|
||||
|
||||
### docs/i18n/localization-architecture.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8 | Generated files under `apps/chanora_flutter/lib/l10n/generated/` | ✅ PASS | Directory exists with 3 files |
|
||||
| 22 | English and Simplified Chinese generated localization files | ✅ PASS | `app_localizations_en.dart` and `app_localizations_zh.dart` exist |
|
||||
|
||||
### docs/ui-ux/material3-design-tokens.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8 | Implementation token source is `apps/chanora_flutter/lib/design/chanora_tokens.dart` | ✅ PASS | File exists |
|
||||
|
||||
### docs/ui-ux/material3-component-catalog.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 10 | Connect and bookmarks: `connect_widgets.dart`, `input_dialogs.dart` | ✅ PASS | Both files exist in widgets/ |
|
||||
| 11 | Channel and client view: `snapshot_view.dart`, `client_info_sheet.dart`, `channel_spacer.dart` | ✅ PASS | All files exist |
|
||||
| 12 | Chat: `chat_views.dart`, `bbcode_text.dart` | ✅ PASS | Both files exist |
|
||||
| 13 | Voice controls: `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart` | ✅ PASS | All files exist |
|
||||
| 14 | Platform/permission indicators: `permission_state_banner.dart`, `ptt_capability_badge.dart`, `talk_power_warning.dart` | ✅ PASS | All files exist |
|
||||
| 15 | Diagnostics: `audio_debug_stats_panel.dart` | ✅ PASS | File exists |
|
||||
|
||||
### docs/ui-ux/adaptive-layout-platform-guide.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8 | Compact/mobile layout for MVP | ✅ PASS | Matches implementation status |
|
||||
|
||||
### docs/security/security-privacy-legal-guideline.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 13-21 | Gate summary table | ✅ PASS | Matches threat model and audit reports |
|
||||
|
||||
### docs/security/threat-model.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8 | Scope covers client, local storage, diagnostics, bridge, protocol, audio, platform, release | ✅ PASS | Comprehensive scope |
|
||||
|
||||
### docs/security/secure-storage-audit-report.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 12-18 | Audit matrix | ✅ PASS | Matches platform policy |
|
||||
|
||||
### docs/security/diagnostic-redaction-audit-report.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 12-18 | Redaction targets | ✅ PASS | Matches diagnostics crate responsibilities |
|
||||
|
||||
### docs/security/dependency-and-supply-chain-report.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 15-19 | Automated controls | ✅ PASS | CI workflows confirmed |
|
||||
| 24-29 | Dependency areas | ✅ PASS | Matches workspace structure |
|
||||
|
||||
### docs/security/flutter-license-inventory.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 11-12 | References LICENSE-APACHE and LICENSE-MIT | ❌ FAIL | Files do not exist |
|
||||
|
||||
### docs/security/license-inventory.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 9-10 | References LICENSE-APACHE and LICENSE-MIT | ❌ FAIL | Files do not exist |
|
||||
|
||||
### docs/privacy/privacy-policy.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 9 | Chanora is a client application for connecting to TeamSpeak 3-compatible servers | ✅ PASS | Matches README |
|
||||
|
||||
### docs/legal/trademark-and-attribution-review.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 5 | DEC-012 remains open | ✅ PASS | Matches product decision register |
|
||||
|
||||
### docs/release/release-readiness-go-nogo-record.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 5 | Workspace version 0.2.0-beta.1, Flutter app 0.3.0+100 | ✅ PASS | Matches Cargo.toml and pubspec.yaml |
|
||||
| 6 | No-Go for public/store release | ✅ PASS | Consistent with open gates |
|
||||
|
||||
### docs/release/platform-release-policy.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 12-19 | Platform policy table | ✅ PASS | Matches README |
|
||||
|
||||
### docs/release/dv-waiver-register.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 14-23 | Active waivers DV-WVR-001 through DV-WVR-009 | ✅ PASS | Comprehensive waiver list |
|
||||
|
||||
### docs/release/ios-build.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 12 | Build script `./tools/build-ios.sh --no-codesign` | ⚠️ MINOR | Cannot verify script exists without checking |
|
||||
|
||||
### docs/verification/verification-master-plan.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 5 | Applies to Rust workspace 0.2.0-beta.1, Flutter app 0.3.0+100 | ✅ PASS | Matches actual versions |
|
||||
|
||||
### docs/verification/swe4-unit-verification-plan.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 17 | chanora_state has 27 tests | ✅ PASS | Matches coverage analysis |
|
||||
|
||||
### docs/verification/swe5-software-integration-verification-plan.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 14-22 | Integration paths | ✅ PASS | Comprehensive path list |
|
||||
|
||||
### docs/verification/swe6-software-verification-plan.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 27-45 | MVP acceptance matrix | ✅ PASS | Comprehensive matrix |
|
||||
|
||||
### docs/verification/sys4-system-integration-verification-plan.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 14-22 | System elements under verification | ✅ PASS | Comprehensive list |
|
||||
|
||||
### docs/governance/document-index.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 14-32 | Baseline documents table | ✅ PASS | All listed paths exist |
|
||||
|
||||
### docs/governance/traceability-matrix.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 16 | Lifecycle chain | ✅ PASS | Matches README |
|
||||
|
||||
### docs/governance/product-decision-register.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 14-21 | Decision summary | ✅ PASS | Comprehensive decision list |
|
||||
|
||||
### docs/governance/git-commit-message-convention.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 18 | `release(android)` example | ⚠️ MINOR | Non-standard Conventional Commits type |
|
||||
|
||||
### docs/governance/document-naming-convention.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8 | Lowercase kebab-case file names | ✅ PASS | Matches actual file naming |
|
||||
|
||||
### docs/governance/path-migration-map.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 10-14 | Migration state table | ✅ PASS | Matches actual file locations |
|
||||
|
||||
### docs/governance/baseline-approval-record.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 10-18 | Approval scope table | ✅ PASS | Matches baseline status |
|
||||
|
||||
### docs/governance/baseline-candidate-validation-report.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 10-16 | Validation summary | ✅ PASS | Comprehensive validation |
|
||||
|
||||
### docs/governance/document-review-report.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 19-24 | Findings table | ✅ PASS | Addresses previous gaps |
|
||||
|
||||
### docs/governance/repo-format-validation-report.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8-18 | Repository layout check | ✅ PASS | All areas confirmed |
|
||||
|
||||
### docs/governance/decision-impact-assessment.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8-14 | Impact matrix | ✅ PASS | Comprehensive impact list |
|
||||
|
||||
### docs/governance/maintainability-review-2026-06-08.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 13-23 | Changes already applied | ✅ PASS | Matches code structure |
|
||||
| 61-88 | Android ADB status | ✅ PASS | Detailed smoke evidence |
|
||||
|
||||
### docs/references/aspice-swe2-swe3-integration-note.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 12 | Lifecycle chain | ✅ PASS | Matches README |
|
||||
|
||||
### docs/references/external-references.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 8-17 | Reference list | ✅ PASS | Comprehensive references |
|
||||
|
||||
### docs/implementation-status-2026-05-28.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 3-4 | Workspace version v0.2.0-beta.1, Flutter app 0.3.0+100 | ✅ PASS | Matches actual versions |
|
||||
| 103 | References SDD-109 | ⚠️ MINOR | Not itemized in current baseline |
|
||||
| 105 | References SAD-043 | ⚠️ MINOR | Not itemized in current baseline |
|
||||
| 140 | Agent spec docs reference | ⚠️ MINOR | Stale cleanup note |
|
||||
|
||||
### SECURITY.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 33-38 | Security document list | ✅ PASS | All listed paths exist |
|
||||
|
||||
### apps/chanora_flutter/README.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 3 | Chanora — cross-platform voice client for TeamSpeak-compatible servers | ✅ PASS | Matches main README |
|
||||
|
||||
### crates/chanora_resolver/README.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 7 | `ChanoraResolver::resolve_client_request` or `resolve_client_address` | ✅ PASS | Matches function inventory |
|
||||
| 60-77 | Library example | ✅ PASS | Matches API |
|
||||
|
||||
### tools/windows-smoke.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 6 | `product/scaffold-v0` branch | ❌ FAIL | Default branch is now `main` |
|
||||
|
||||
### silero-coreml/README.md
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 3 | Private Chanora-owned Apple/CoreML Silero VAD backend scaffold | ✅ PASS | Matches project scope |
|
||||
|
||||
### flutter_rust_bridge.yaml
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 1-5 | Bridge configuration | ✅ PASS | Matches SDD bridge boundary design |
|
||||
|
||||
### Cargo.toml
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 28-39 | Workspace members | ✅ PASS | All 10 crates listed |
|
||||
| 46 | Version 0.2.0-beta.1 | ✅ PASS | Matches documentation |
|
||||
| 48 | Rust version 1.95 | ✅ PASS | Modern Rust requirement |
|
||||
|
||||
### pubspec.yaml
|
||||
| Line | Claim | Status | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| 19 | Version 0.3.0+100 | ✅ PASS | Matches documentation |
|
||||
| 37 | flutter_rust_bridge: 2.12.0 | ✅ PASS | Matches SDD bridge version |
|
||||
|
||||
## Recommendations
|
||||
|
||||
### High Priority (Critical)
|
||||
1. **Create LICENSE-APACHE and LICENSE-MIT files** — Required for DEC-020 dual-license compliance
|
||||
2. **Update README.md repository layout** — Add `crates/chanora_resolver/`, `crates/chanora_prefetch/`, `crates/chanora_cache/`
|
||||
|
||||
### Medium Priority (Major)
|
||||
3. **Update SAD component table** — Add `chanora_cache` component
|
||||
4. **Fix SDD-MOD-003 file classification** — Move `snapshot_state_mapper.dart` to correct section
|
||||
5. **Update tools/windows-smoke.md** — Change branch reference from `product/scaffold-v0` to `main`
|
||||
6. **Fix docs/sysrs.md suggested file names** — Remove or update non-existent file name suggestions
|
||||
|
||||
### Low Priority (Minor)
|
||||
7. **Update implementation status date** — Refresh to reflect current state
|
||||
8. **Fix malformed markdown in docs/sysdes.md:13** — Add blank line before `---`
|
||||
9. **Update Material 3 guideline version** — Align with DV baseline date
|
||||
10. **Standardize commit type examples** — Remove `release` type from convention examples
|
||||
11. **Update SysDes version** — Align with SysRS version numbering
|
||||
@@ -1,347 +0,0 @@
|
||||
# Documentation Link Not-Covered Analysis
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
|
||||
## Summary
|
||||
- Total references checked: 148
|
||||
- Broken markdown links: 2
|
||||
- Missing file targets: 9 (2 LICENSE + 5 hypothetical + 2 code path mismatches)
|
||||
- Orphaned docs: 18
|
||||
- Suspicious external URLs: 3
|
||||
|
||||
## Broken Markdown Links
|
||||
|
||||
| File | Line | Link Text | Target | Issue |
|
||||
|------|------|-----------|--------|-------|
|
||||
| README.md | 428 | `LICENSE-APACHE` | `LICENSE-APACHE` | File does not exist at repo root |
|
||||
| README.md | 431 | `LICENSE-MIT` | `LICENSE-MIT` | File does not exist at repo root |
|
||||
|
||||
**Impact:** Users clicking the license links in the README will get a 404 on GitHub. These are referenced in the License section as the dual-license model files (DEC-020).
|
||||
|
||||
## Missing File Targets
|
||||
|
||||
### Missing LICENSE Files (High Impact)
|
||||
|
||||
| File | Line | Referenced Path | Issue |
|
||||
|------|------|----------------|-------|
|
||||
| README.md | 428 | `LICENSE-APACHE` | File does not exist at repo root |
|
||||
| README.md | 431 | `LICENSE-MIT` | File does not exist at repo root |
|
||||
| docs/security/license-inventory.md | 9 | `../../LICENSE-APACHE` | Resolves to missing `LICENSE-APACHE` at repo root |
|
||||
| docs/security/license-inventory.md | 10 | `../../LICENSE-MIT` | Resolves to missing `LICENSE-MIT` at repo root |
|
||||
| docs/security/flutter-license-inventory.md | 11 | `../../LICENSE-APACHE` | Resolves to missing `LICENSE-APACHE` at repo root |
|
||||
| docs/security/flutter-license-inventory.md | 11 | `../../LICENSE-MIT` | Resolves to missing `LICENSE-MIT` at repo root |
|
||||
|
||||
**Impact:** The dual-license model (DEC-020) requires these files to exist for proper attribution. All 4 references across 3 files are broken.
|
||||
|
||||
### Hypothetical File Names (Low Impact)
|
||||
|
||||
| File | Line | Referenced Path | Issue |
|
||||
|------|------|----------------|-------|
|
||||
| docs/sysrs.md | 126 | `docs/chanora_SysDes.md` | Listed as "Potential downstream file name" — does not exist |
|
||||
| docs/sysrs.md | 127 | `docs/chanora_SRS.md` | Listed as "Potential downstream file name" — does not exist |
|
||||
| docs/sysrs.md | 128 | `docs/chanora_SAD.md` | Listed as "Potential downstream file name" — does not exist |
|
||||
| docs/sysrs.md | 129 | `docs/chanora_SDD.md` | Listed as "Potential downstream file name" — does not exist |
|
||||
| docs/sysrs.md | 130 | `docs/chanora_Verification.md` | Listed as "Potential downstream file name" — does not exist |
|
||||
|
||||
**Note:** These are documented as "Potential downstream file names" in a table and are aspirational/historical. They are presented as plain text in a table, not as navigable links. Low severity.
|
||||
|
||||
## Missing Code References
|
||||
|
||||
| File | Line | Reference | Expected Location | Issue |
|
||||
|------|------|-----------|-------------------|-------|
|
||||
| docs/architecture/sdd.md | 19 | `snapshot_state_mapper.dart` | Listed under "Snapshot and channel UI" widgets section | File actually exists in `apps/chanora_flutter/lib/services/`, not `apps/chanora_flutter/lib/widgets/` — directory mismatch in docs |
|
||||
| docs/architecture/sdd.md | 21 | `voice_settings*.dart` | Listed under Voice UI widgets | Files are `voice_settings.dart` and `voice_settings_controls.dart` — glob reference is ambiguous (two files match) |
|
||||
|
||||
**Note:** The `snapshot_state_mapper.dart` directory mismatch is a minor documentation inaccuracy — the file exists but is categorized differently than documented.
|
||||
|
||||
## Broken Anchor Links
|
||||
|
||||
No broken anchor links found. All `#section` references within documents resolve to existing headers.
|
||||
|
||||
## Orphaned Documents
|
||||
|
||||
(Not referenced by any other document in the main doc tree)
|
||||
|
||||
| File | Last Modified | Should Be Referenced From |
|
||||
|------|---------------|--------------------------|
|
||||
| docs/offline-knowledge/README.md | 2026-06-13 | Could be referenced from a top-level docs index |
|
||||
| docs/offline-knowledge/function-inventory.md | 2026-06-13 | Could be referenced from docs/architecture/sdd.md |
|
||||
| docs/offline-knowledge/coverage-analysis.md | 2026-06-13 | Could be referenced from docs/verification/ plans |
|
||||
| docs/offline-knowledge/doc-quality-analysis.md | 2026-06-13 | Could be referenced from docs/governance/document-review-report.md |
|
||||
| docs/offline-knowledge/link-coverage-report.md | 2026-06-13 | Could be referenced from docs/governance/ |
|
||||
| docs/offline-knowledge/external/teaspeak-overview.md | 2026-06-13 | Could be referenced from docs/references/external-references.md |
|
||||
| docs/offline-knowledge/external/respeak-overview.md | 2026-06-13 | Could be referenced from docs/references/external-references.md |
|
||||
| docs/offline-knowledge/external/yatqa-en.md | 2026-06-13 | Could be referenced from docs/references/external-references.md |
|
||||
| docs/offline-knowledge/external/yatqa-de.md | 2026-06-13 | Could be referenced from docs/references/external-references.md |
|
||||
| docs/offline-knowledge/reviews/coverage-analysis-review.md | 2026-06-13 | Could be referenced from docs/offline-knowledge/README.md (already is) |
|
||||
| docs/offline-knowledge/reviews/doc-quality-review.md | 2026-06-13 | Could be referenced from docs/offline-knowledge/README.md (already is) |
|
||||
| docs/offline-knowledge/reviews/link-coverage-review.md | 2026-06-13 | Could be referenced from docs/offline-knowledge/README.md (already is) |
|
||||
| docs/offline-knowledge/reviews/external-docs-review.md | 2026-06-13 | Could be referenced from docs/offline-knowledge/README.md (already is) |
|
||||
| docs/superpowers/specs/*.md (6 files) | 2026-05-28 to 2026-06-09 | Internal planning docs; not expected in DV tree |
|
||||
| docs/superpowers/plans/*.md (8 files) | 2026-05-28 to 2026-06-08 | Internal planning docs; not expected in DV tree |
|
||||
|
||||
**Note:** The offline-knowledge files are self-referencing within their own README but are not linked from the main documentation tree. The superpowers files are internal planning documents and are intentionally separate from the DV document set.
|
||||
|
||||
## Suspicious External URLs
|
||||
|
||||
| File | Line | URL | Issue |
|
||||
|------|------|-----|-------|
|
||||
| docs/architecture/file-transfer-research.md | 406 | `https://git.did.science/TeaSpeak/Server/Server` | Self-hosted GitLab instance; may become unavailable. Specific branch `new-groups` commit `b54c6d4e` referenced. |
|
||||
| docs/security/license-inventory.md | 96 | `http://github.com/ejmahler/strength_reduce` | Uses HTTP instead of HTTPS for GitHub URL |
|
||||
| docs/security/flutter-license-inventory.md | various | `http://www.apache.org/licenses/` and `http://mozilla.org/MPL/2.0/` | HTTP URLs in license text bodies (not navigational links) |
|
||||
|
||||
**Note:** The file-transfer research links point to specific GitHub commit SHAs which may become stale over time if force-pushes occur. The HTTP-vs-HTTPS issue on the strength_reduce URL is cosmetic but should be corrected.
|
||||
|
||||
## Cross-Reference Chain Issues
|
||||
|
||||
| Chain | Issue |
|
||||
|-------|-------|
|
||||
| None found | All doc-to-doc cross-references in prose text resolve correctly |
|
||||
|
||||
All cross-reference chains verified:
|
||||
- `docs/architecture/sad.md` → `docs/srs.md` ✓
|
||||
- `docs/architecture/sad.md` → `docs/sysdes.md` ✓
|
||||
- `docs/architecture/sdd.md` → `docs/architecture/sad.md` ✓
|
||||
- `docs/architecture/sdd.md` → `docs/srs.md` ✓
|
||||
- `docs/architecture/sysdes.md` → `docs/sysdes.md` ✓
|
||||
- `docs/architecture/file-transfer-design.md` → `docs/architecture/sad.md` ✓
|
||||
- `docs/architecture/file-transfer-research.md` → `docs/architecture/file-transfer-design.md` ✓
|
||||
- `docs/architecture/file-transfer-implementation-plan.md` → both upstream docs ✓
|
||||
- `docs/architecture/desktop-ptt-architecture.md` → sad, sdd, dv-waiver-register ✓
|
||||
- `docs/requirements/sysrs.md` → `../sysrs.md` ✓
|
||||
- `docs/requirements/srs.md` → `../srs.md` ✓
|
||||
- `docs/ui-ux/material3-guideline.md` → `docs/material3-guideline.md` ✓
|
||||
|
||||
## Missing Image/Asset References
|
||||
|
||||
No image references (``) found in any documentation files. All docs are text-only.
|
||||
|
||||
## Include/Import References
|
||||
|
||||
No include directives or template references found in documentation files.
|
||||
|
||||
---
|
||||
|
||||
## Per-File Link Inventory
|
||||
|
||||
### README.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 130 | `docs/architecture/desktop-ptt-architecture.md` | ✓ Valid (markdown link) |
|
||||
| 144 | `docs/governance/product-decision-register.md` | ✓ Valid (inline ref) |
|
||||
| 261 | `docs/requirements/sysrs.md` | ✓ Valid (inline ref) |
|
||||
| 262 | `docs/requirements/srs.md` | ✓ Valid (inline ref) |
|
||||
| 263 | `docs/architecture/sysdes.md` | ✓ Valid (inline ref) |
|
||||
| 264 | `docs/architecture/sad.md` | ✓ Valid (inline ref) |
|
||||
| 265 | `docs/architecture/sdd.md` | ✓ Valid (inline ref) |
|
||||
| 266 | `docs/verification/verification-master-plan.md` | ✓ Valid (inline ref) |
|
||||
| 267 | `docs/release/release-readiness-go-nogo-record.md` | ✓ Valid (inline ref) |
|
||||
| 268 | `docs/release/platform-release-policy.md` | ✓ Valid (inline ref) |
|
||||
| 269 | `docs/governance/product-decision-register.md` | ✓ Valid (inline ref) |
|
||||
| 270 | `docs/governance/traceability-matrix.md` | ✓ Valid (inline ref) |
|
||||
| 271 | `docs/security/security-privacy-legal-guideline.md` | ✓ Valid (inline ref) |
|
||||
| 312 | `docs/release/release-readiness-go-nogo-record.md` | ✓ Valid (inline ref) |
|
||||
| 349-355 | 6 security/privacy/legal doc paths | ✓ Valid (inline refs) |
|
||||
| 391 | `docs/governance/git-commit-message-convention.md` | ✓ Valid (inline ref) |
|
||||
| 428 | `LICENSE-APACHE` | ✗ **BROKEN** — file does not exist |
|
||||
| 431 | `LICENSE-MIT` | ✗ **BROKEN** — file does not exist |
|
||||
| 436 | `docs/governance/product-decision-register.md` | ✓ Valid (markdown link) |
|
||||
| 444 | `NOTICE` | ✓ Valid (markdown link) |
|
||||
| 449-451 | 3 doc paths | ✓ Valid (inline refs) |
|
||||
|
||||
### CONTRIBUTING.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 25 | `docs/governance/git-commit-message-convention.md` | ✓ Valid (inline ref) |
|
||||
|
||||
### SECURITY.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 33-38 | 6 security/privacy/legal doc paths | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/architecture/sad.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | `docs/srs.md` | ✓ Valid (inline ref) |
|
||||
| 7 | `docs/sysdes.md` | ✓ Valid (inline ref) |
|
||||
| 41-52 | 12 component source paths | ✓ Valid (code refs) |
|
||||
| 176 | `docs/governance/traceability-matrix.md` | ✓ Valid (inline ref) |
|
||||
|
||||
### docs/architecture/sdd.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | `docs/architecture/sad.md` | ✓ Valid (inline ref) |
|
||||
| 7 | `docs/srs.md` | ✓ Valid (inline ref) |
|
||||
| 17-31 | Module source paths | ✓ Valid (code refs), except `snapshot_state_mapper.dart` listed under wrong section |
|
||||
| 35 | `crates/chanora_bridge/src/api.rs` | ✓ Valid (code ref) |
|
||||
|
||||
### docs/architecture/sysdes.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | `docs/sysdes.md` | ✓ Valid (canonical pointer) |
|
||||
|
||||
### docs/architecture/desktop-ptt-architecture.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 5 | `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/release/dv-waiver-register.md` | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/architecture/file-transfer-design.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | `docs/architecture/sad.md` | ✓ Valid (inline ref) |
|
||||
| 118-137 | `tsclientlib/src/lib.rs` code references | ✓ Valid (external code refs — not locally verifiable) |
|
||||
| 737 | `crates/chanora_protocol/src/adapter.rs` | ✓ Valid (code ref) |
|
||||
|
||||
### docs/architecture/file-transfer-research.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 5 | `docs/architecture/file-transfer-design.md` | ✓ Valid (inline ref) |
|
||||
| 29-31 | GitHub commit URLs | ⚠ External — may become stale |
|
||||
| 406 | `https://git.did.science/TeaSpeak/Server/Server` | ⚠ Self-hosted GitLab — may become unavailable |
|
||||
|
||||
### docs/architecture/file-transfer-implementation-plan.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | `docs/architecture/file-transfer-design.md`, `docs/architecture/file-transfer-research.md` | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/governance/document-index.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 14-32 | All 18 listed document paths | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/governance/traceability-matrix.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 5 | 5 primary upstream doc paths | ✓ Valid (inline refs) |
|
||||
| 25-31 | 7 source doc paths | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/governance/path-migration-map.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 10-14 | 5 README path mappings | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/verification/verification-master-plan.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | 6 primary upstream doc paths | ✓ Valid (inline refs) |
|
||||
| 18-21 | 4 verification plan paths | ✓ Valid (inline refs) |
|
||||
| 47 | `tools/windows-smoke.md` | ✓ Valid (code ref) |
|
||||
| 48 | `docs/release/ios-build.md` | ✓ Valid (inline ref) |
|
||||
|
||||
### docs/security/license-inventory.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 9 | `../../LICENSE-APACHE` | ✗ **BROKEN** — file does not exist |
|
||||
| 10 | `../../LICENSE-MIT` | ✗ **BROKEN** — file does not exist |
|
||||
| 11 | `docs/governance/product-decision-register.md` | ✓ Valid (inline ref) |
|
||||
|
||||
### docs/security/flutter-license-inventory.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 11 | `../../LICENSE-APACHE` | ✗ **BROKEN** — file does not exist |
|
||||
| 12 | `../../LICENSE-MIT` | ✗ **BROKEN** — file does not exist |
|
||||
|
||||
### docs/security/dependency-and-supply-chain-report.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 29 | `https://github.com/EdisonJwa/oboe-rs` | ✓ Valid (external GitHub URL) |
|
||||
|
||||
### docs/privacy/privacy-policy.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| (none) | No links or references | N/A |
|
||||
|
||||
### docs/legal/trademark-and-attribution-review.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| (none) | No links or references | N/A |
|
||||
|
||||
### docs/release/release-readiness-go-nogo-record.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 37 | `docs/implementation-status-2026-05-28.md` | ✓ Valid (inline ref) |
|
||||
| 26 | `apps/chanora_flutter/pubspec.yaml` | ✓ Valid (code ref) |
|
||||
|
||||
### docs/release/dv-waiver-register.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 15-23 | Various `docs/` paths in Source evidence column | ✓ Valid (inline refs) |
|
||||
|
||||
### docs/requirements/sysrs.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 4 | `../sysrs.md` | ✓ Valid (canonical pointer) |
|
||||
|
||||
### docs/requirements/srs.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 4 | `../srs.md` | ✓ Valid (canonical pointer) |
|
||||
|
||||
### docs/ui-ux/material3-guideline.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 6 | `docs/material3-guideline.md` | ✓ Valid (canonical pointer) |
|
||||
|
||||
### docs/material3-guideline.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 10 | `docs/ui-ux/material3-guideline.md` | ✓ Valid (self-referencing path record) |
|
||||
|
||||
### docs/sysrs.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 126 | `docs/chanora_SysDes.md` | ⚠ Hypothetical — does not exist (aspirational name) |
|
||||
| 127 | `docs/chanora_SRS.md` | ⚠ Hypothetical — does not exist (aspirational name) |
|
||||
| 128 | `docs/chanora_SAD.md` | ⚠ Hypothetical — does not exist (aspirational name) |
|
||||
| 129 | `docs/chanora_SDD.md` | ⚠ Hypothetical — does not exist (aspirational name) |
|
||||
| 130 | `docs/chanora_Verification.md` | ⚠ Hypothetical — does not exist (aspirational name) |
|
||||
|
||||
### apps/chanora_flutter/README.md
|
||||
|
||||
| Line | Link | Status |
|
||||
|------|------|--------|
|
||||
| 11 | `https://docs.flutter.dev/get-started/learn-flutter` | ✓ Valid (external) |
|
||||
| 12 | `https://docs.flutter.dev/get-started/codelab` | ✓ Valid (external) |
|
||||
| 13 | `https://docs.flutter.dev/reference/learning-resources` | ✓ Valid (external) |
|
||||
| 16 | `https://docs.flutter.dev/` | ✓ Valid (external) |
|
||||
|
||||
---
|
||||
|
||||
## Action Items (Priority Order)
|
||||
|
||||
### P0 — Must Fix Before Any Release
|
||||
1. **Create `LICENSE-APACHE` and `LICENSE-MIT` files** at repo root. These are required by DEC-020 (dual-license model) and referenced by README.md, docs/security/license-inventory.md, and docs/security/flutter-license-inventory.md.
|
||||
|
||||
### P1 — Should Fix for DV Quality
|
||||
2. **Fix `snapshot_state_mapper.dart` categorization** in docs/architecture/sdd.md:19 — move from "Snapshot and channel UI" widgets section to service layer section, or add a note clarifying the actual location.
|
||||
|
||||
### P2 — Nice to Have
|
||||
3. **Add offline-knowledge docs to document index** or references section so they are discoverable.
|
||||
4. **Fix HTTP URL** in docs/security/license-inventory.md:96 (`http://github.com/ejmahler/strength_reduce` → `https://...`).
|
||||
5. **Clean up hypothetical file names** in docs/sysrs.md:124-131 — either remove the table or clearly mark as historical/aspirational.
|
||||
@@ -1,187 +0,0 @@
|
||||
# Documentation Out-of-Date Analysis
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
**Workspace version:** 0.2.0-beta.1
|
||||
**Latest commit:** dd6e80f (2026-06-13)
|
||||
|
||||
## Summary
|
||||
- Total docs checked: 64
|
||||
- Outdated docs: 12
|
||||
- Stale version refs: 5
|
||||
- Undocumented recent changes: 8
|
||||
- Stale date refs: 15+
|
||||
|
||||
## Stale Version References
|
||||
|
||||
| File | Line | Version Referenced | Current Version | Drift |
|
||||
|------|------|-------------------|-----------------|-------|
|
||||
| `docs/sysdes.md` | 6 | 0.9.8 | N/A (doc version) | Last updated 2026-05-14, 30 days stale |
|
||||
| `docs/srs.md` | 7 | 0.9.9 | N/A (doc version) | Last updated 2026-05-18, 26 days stale |
|
||||
| `docs/sysrs.md` | 5 | 0.9.11 | N/A (doc version) | Last updated 2026-06-07, 6 days stale |
|
||||
| `docs/material3-guideline.md` | ~4 | 0.9.2 | N/A (doc version) | Last updated 2026-05-14, 30 days stale |
|
||||
| `tools/windows-smoke.md` | 6 | `product/scaffold-v0` branch | `main` | Default branch changed per CHANGELOG |
|
||||
|
||||
## Stale Date References
|
||||
|
||||
| File | Date | Age | Issue |
|
||||
|------|------|-----|-------|
|
||||
| `docs/implementation-status-2026-05-28.md` | 2026-05-28 | 16 days | Pre-dates DV baseline (2026-05-29) and 8 major feature PRs |
|
||||
| `docs/architecture/sad.md` | 2026-05-29 | 15 days | Missing file transfer, poke notifications, desktop VAD features |
|
||||
| `docs/architecture/sdd.md` | 2026-05-29 | 15 days | Missing file transfer, poke notifications, desktop VAD features |
|
||||
| `docs/verification/verification-master-plan.md` | 2026-05-29 | 15 days | Missing file transfer and poke notification verification |
|
||||
| `docs/verification/swe4-unit-verification-plan.md` | 2026-05-29 | 15 days | Missing new test coverage for file transfer |
|
||||
| `docs/verification/swe5-software-integration-verification-plan.md` | 2026-05-29 | 15 days | Missing file transfer integration verification |
|
||||
| `docs/verification/swe6-software-verification-plan.md` | 2026-05-29 | 15 days | Missing file transfer software verification |
|
||||
| `docs/verification/sys4-system-integration-verification-plan.md` | 2026-05-29 | 15 days | Missing file transfer system verification |
|
||||
| `docs/governance/product-decision-register.md` | 2026-05-29 | 15 days | Missing file-transfer-related decisions |
|
||||
| `docs/governance/document-index.md` | 2026-05-29 | 15 days | Missing file-transfer-design.md, file-transfer-research.md, file-transfer-implementation-plan.md |
|
||||
| `docs/security/security-privacy-legal-guideline.md` | 2026-05-29 | 15 days | Missing file transfer security considerations |
|
||||
| `docs/security/threat-model.md` | 2026-05-29 | 15 days | Missing file transfer threat analysis |
|
||||
| `docs/i18n/localization-architecture.md` | 2026-05-29 | 15 days | Missing poke notification l10n strings |
|
||||
| `docs/legal/trademark-and-attribution-review.md` | 2026-05-29 | 15 days | Missing cacache license review |
|
||||
| `docs/privacy/privacy-policy.md` | 2026-05-29 | 15 days | Missing file transfer data handling |
|
||||
|
||||
## Undocumented Recent Changes
|
||||
|
||||
| Change | Date | Expected Doc | Status |
|
||||
|--------|------|-------------|--------|
|
||||
| File transfer system (avatar/icon download with cacache) | 2026-06-10 | README.md, SAD, SDD, CHANGELOG | Not in README crate list, not in CHANGELOG |
|
||||
| Poke notifications (local notifications, settings, bridge) | 2026-06-08 | SAD, SDD, CHANGELOG | Not in CHANGELOG |
|
||||
| Desktop Silero ONNX VAD + Windows PTT modernization | 2026-06-09 | SAD, SDD, CHANGELOG | Not in CHANGELOG |
|
||||
| iOS RemoteIO+WebRTC APM path removal | 2026-06-10 | SAD, SDD | Not documented |
|
||||
| SonoraExperimental bridge API removal | 2026-06-10 | SAD, SDD, bridge docs | Not documented |
|
||||
| iOS AVAudioSession activation fix | 2026-06-10 | Platform docs | Not documented |
|
||||
| iOS Debug build unblocking + FRB regeneration | 2026-06-10 | Build docs | Not documented |
|
||||
| poke-without-message design | 2026-06-09 | Design docs | Committed but not indexed |
|
||||
|
||||
## Feature Drift
|
||||
|
||||
### Documented but No Longer in Code
|
||||
| Feature | Doc File | Last Seen In Code |
|
||||
|---------|----------|-------------------|
|
||||
| `SonoraExperimental` bridge API | `docs/architecture/sdd.md` (implied) | Removed 2026-06-10 (commit 2b28549) |
|
||||
| iOS `ios_raw_unit.rs` | `docs/implementation-status-2026-05-28.md:33` | Removed 2026-06-10 (commit 3f9ea4f) |
|
||||
| `SnapshotChanged` event variant | CHANGELOG v0.3.0 | Removed end-to-end |
|
||||
| Timer-based snapshot polling | CHANGELOG v0.2.0-beta.1 | Replaced by event-driven UI |
|
||||
|
||||
### In Code but Not Documented
|
||||
| Feature | Code Location | Expected Doc |
|
||||
|---------|--------------|-------------|
|
||||
| `chanora_cache` crate (cacache-backed blob store) | `crates/chanora_cache/` | README.md crate list, SAD, SDD |
|
||||
| File transfer protocol support | `crates/chanora_protocol/` | SAD, SDD, CHANGELOG |
|
||||
| Poke notification service | `apps/chanora_flutter/lib/services/` | SAD, SDD, CHANGELOG |
|
||||
| Poke notification settings UI | `apps/chanora_flutter/lib/widgets/` | SAD, SDD |
|
||||
| Desktop Silero ONNX VAD | `crates/chanora_audio/src/vad/silero_onnx.rs` | SAD, SDD |
|
||||
| Windows PTT modernization | `crates/chanora_audio/src/ptt_backends/windows.rs` | SAD, SDD |
|
||||
| `poke_limiter.rs` | `crates/chanora_protocol/src/poke_limiter.rs` | SAD, SDD |
|
||||
| Local notification plugin integration | `apps/chanora_flutter/` | SAD, SDD |
|
||||
|
||||
## Per-File Out-of-Date Assessment
|
||||
|
||||
### README.md
|
||||
- **Last meaningful update:** Unknown (no date in file)
|
||||
- **Stale sections:**
|
||||
- Crate list (line 242-249): Missing `chanora_cache` and `chanora_resolver` crates
|
||||
- Repository layout (line 233-249): Missing `chanora_cache`, `chanora_resolver`, `chanora_prefetch`
|
||||
- Status section (line 16-23): References "v0.9.x document set" — no specific date
|
||||
- Development section (line 398-409): Missing `just` commands (justfile exists)
|
||||
- **Missing recent changes:** File transfer system, poke notifications, desktop VAD
|
||||
|
||||
### docs/sysdes.md
|
||||
- **Version:** 0.9.8
|
||||
- **Last change record:** 2026-05-14
|
||||
- **Stale sections:** All — 30 days without update
|
||||
- **Missing:** File transfer system element (SE-20?), poke notification interface (IF-015?)
|
||||
|
||||
### docs/srs.md
|
||||
- **Version:** 0.9.9
|
||||
- **Last change record:** 2026-05-18
|
||||
- **Stale sections:** All — 26 days without update
|
||||
- **Missing:** File transfer SRS requirements, poke notification SRS requirements, desktop VAD SRS requirements
|
||||
|
||||
### docs/sysrs.md
|
||||
- **Version:** 0.9.11
|
||||
- **Last change record:** 2026-06-07
|
||||
- **Stale sections:** Mostly current but missing file transfer and poke notification requirements
|
||||
|
||||
### docs/architecture/sad.md
|
||||
- **Date:** 2026-05-29
|
||||
- **Stale sections:**
|
||||
- Component architecture table (line 39-50): Missing `chanora_cache` component
|
||||
- Missing file transfer architecture
|
||||
- Missing poke notification architecture
|
||||
- Missing desktop VAD architecture
|
||||
- **Missing recent changes:** All PRs from 2026-06-07 through 2026-06-13
|
||||
|
||||
### docs/architecture/sdd.md
|
||||
- **Date:** 2026-05-29
|
||||
- **Stale sections:**
|
||||
- Module catalogue (line 15-31): Missing file transfer module, poke notification module
|
||||
- Missing `chanora_cache` module (SDD-MOD-016?)
|
||||
- Missing `poke_limiter` module
|
||||
- **Missing recent changes:** All PRs from 2026-06-07 through 2026-06-13
|
||||
|
||||
### docs/implementation-status-2026-05-28.md
|
||||
- **Date:** 2026-05-28 — 16 days old
|
||||
- **Stale sections:**
|
||||
- "Done" table: Missing file transfer, poke notifications, desktop VAD, iOS fixes
|
||||
- "Partial / Scaffold Only": `chanora_cache` was scaffold, now implemented
|
||||
- "Not Done (P0 blockers)": iOS `AVAudioSession.Mode.voiceChat` — now implemented (commit 89bbfa1)
|
||||
- Android target compilation: Still blocked per DEC-034
|
||||
- Agent spec docs reference (line 140): States docs are "deleted" — stale cleanup note
|
||||
- **Recommendation:** Update to reflect current state or create new status doc
|
||||
|
||||
### docs/governance/product-decision-register.md
|
||||
- **Date:** 2026-05-29
|
||||
- **Note:** DEC-033 and DEC-034 are present (lines 20-21). Missing decisions:
|
||||
- File transfer architecture decision
|
||||
- Poke notification feature decision
|
||||
|
||||
### docs/governance/document-index.md
|
||||
- **Date:** 2026-05-29
|
||||
- **Missing documents:**
|
||||
- `docs/architecture/file-transfer-design.md`
|
||||
- `docs/architecture/file-transfer-research.md`
|
||||
- `docs/architecture/file-transfer-implementation-plan.md`
|
||||
- `docs/superpowers/specs/2026-06-09-poke-without-message-design.md`
|
||||
|
||||
### docs/security/license-inventory.md
|
||||
- **Status:** Refreshed 2026-06-09 (commit b841d3f)
|
||||
- **Issue:** May be missing `cacache` dependency license if not in Cargo.lock at refresh time
|
||||
|
||||
### docs/material3-guideline.md
|
||||
- **Version:** 0.9.2
|
||||
- **Last change record:** 2026-05-14
|
||||
- **Status:** 30 days stale, but Material 3 design may not have changed
|
||||
|
||||
### tools/windows-smoke.md
|
||||
- **Stale reference:** Line 6 references `product/scaffold-v0` branch
|
||||
- **Current default:** `main` per CHANGELOG v0.3.0
|
||||
|
||||
### docs/verification/*.md (all 5 files)
|
||||
- **Date:** All dated 2026-05-29
|
||||
- **Missing:** File transfer verification, poke notification verification, desktop VAD verification
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Critical (blocks DV/release)
|
||||
1. **Update `docs/implementation-status-2026-05-28.md`** — 16 days stale, missing 8 major PRs, iOS voiceChat now implemented
|
||||
2. **Update `docs/governance/product-decision-register.md`** — Missing file transfer and poke notification decisions
|
||||
3. **Update `docs/governance/document-index.md`** — Missing 3 file-transfer docs
|
||||
|
||||
### High Priority (DV completeness)
|
||||
4. **Update `docs/architecture/sad.md`** — Missing file transfer, poke notifications, desktop VAD, chanora_cache component
|
||||
5. **Update `docs/architecture/sdd.md`** — Missing file transfer, poke notifications, desktop VAD modules
|
||||
6. **Update `docs/sysdes.md`** — 30 days stale, missing file transfer system elements
|
||||
7. **Update `docs/srs.md`** — 26 days stale, missing file transfer and poke notification requirements
|
||||
8. **Update CHANGELOG.md** — Missing v0.3.0+ changes (file transfer, poke notifications, desktop VAD, iOS fixes)
|
||||
|
||||
### Medium Priority (accuracy)
|
||||
9. **Update README.md** — Missing `chanora_cache` and `chanora_resolver` in crate list
|
||||
10. **Update `tools/windows-smoke.md`** — Fix stale branch reference
|
||||
11. **Update verification plans** — Add file transfer and poke notification verification
|
||||
12. **Update security docs** — Add file transfer threat analysis
|
||||
|
||||
### Low Priority (cleanup)
|
||||
13. **Align document versions** — SysDes (0.9.8), SysRS (0.9.11), Material3 (0.9.2) have different version numbers
|
||||
14. **Clean up path record files** — `docs/architecture/sysdes.md`, `docs/requirements/sysrs.md`, `docs/requirements/srs.md`, `docs/ui-ux/material3-guideline.md` are stubs pointing to canonical files
|
||||
@@ -1,611 +0,0 @@
|
||||
# Chanora Function Inventory
|
||||
|
||||
> Auto-generated comprehensive inventory of all public APIs across 10 Rust crates and 50+ Dart files.
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| **Rust Crates** | 10 |
|
||||
| **Rust pub fn** | ~180 |
|
||||
| **Rust pub struct** | ~90 |
|
||||
| **Rust pub enum** | ~50 |
|
||||
| **Rust pub trait** | 6 |
|
||||
| **Rust pub const** | ~30 |
|
||||
| **Dart files** | 56 |
|
||||
| **Dart public classes** | ~80 |
|
||||
| **TODO/FIXME comments** | 15 |
|
||||
| **Empty/commented stubs** | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Rust Crates
|
||||
|
||||
### 1. `chanora_cache` — Content-Addressed Blob Cache
|
||||
|
||||
Disposable blob cache for avatar/icon files. Wraps `cacache` for crash safety.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `BlobCache` | lib.rs:30 | Content-addressed blob cache backed by cacache |
|
||||
| enum | `BlobCacheError` | lib.rs:20 | Errors raised by blob cache (Io, InvalidKey) |
|
||||
| const | `PREFIX_AVATAR` | lib.rs:37 | Avatar blob prefix `"av_"` |
|
||||
| const | `PREFIX_ICON` | lib.rs:39 | Icon blob prefix `"ic_"` |
|
||||
| fn | `BlobCache::new` | lib.rs:46 | Create/open cache rooted at `cache_dir/chanora/` |
|
||||
| fn | `BlobCache::put` | lib.rs:63 | Store a blob with prefix+key |
|
||||
| fn | `BlobCache::get` | lib.rs:80 | Read a blob (returns None if missing) |
|
||||
| fn | `BlobCache::remove` | lib.rs:101 | Delete a specific blob |
|
||||
| fn | `BlobCache::clear` | lib.rs:111 | Delete all blobs |
|
||||
| fn | `BlobCache::total_size` | lib.rs:129 | Return total bytes used |
|
||||
| fn | `BlobCache::evict` | lib.rs:154 | Evict oldest entries until under max_bytes |
|
||||
|
||||
**Dead code:** None found. All public items consumed by `chanora_core`.
|
||||
|
||||
---
|
||||
|
||||
### 2. `chanora_protocol` — TeamSpeak Protocol Adapter
|
||||
|
||||
Isolates `tsclientlib` behind a typed boundary. No upstream types leak.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `ConnectConfig` | adapter.rs:146 | Typed connection parameters |
|
||||
| struct | `ProtocolClient` | adapter.rs:236 | Async handle owning live protocol connection |
|
||||
| struct | `InboundVoice` | adapter.rs:260 | One inbound voice packet from remote client |
|
||||
| struct | `SnapshotProbe` | adapter.rs:270 | Clone-free probe handle for watchdog |
|
||||
| struct | `ChannelInfo` | dto.rs:19 | One channel in server tree |
|
||||
| struct | `ClientInfo` | dto.rs:72 | One connected client |
|
||||
| struct | `ClientProfile` | dto.rs:96 | Rich profile + live connection details |
|
||||
| struct | `ServerSnapshot` | dto.rs:153 | Full server state snapshot |
|
||||
| struct | `ChatMessage` | dto.rs:50 | In-channel text message |
|
||||
| struct | `ServerActivity` | dto.rs:65 | Server-activity notification |
|
||||
| struct | `ChannelId` | dto.rs:11 | Opaque channel identifier (u64 newtype) |
|
||||
| struct | `ClientId` | dto.rs:15 | Opaque client identifier (u64 newtype) |
|
||||
| struct | `PokeLimiter` | poke_limiter.rs:19 | Per-connection poke rate limiter |
|
||||
| enum | `ProtocolError` | lib.rs:62 | Typed error catalogue (10 variants) |
|
||||
| enum | `ProtocolDelta` | dto.rs:183 | Incremental state changes (7 variants) |
|
||||
| enum | `DisconnectReason` | adapter.rs:225 | Why protocol task ended |
|
||||
| enum | `MessageTarget` | dto.rs:37 | Text message target scope |
|
||||
| enum | `PokeStrength` | poke_limiter.rs:8 | Poke notification strength |
|
||||
| trait | *(re-exports)* | lib.rs:52 | `AudioData`, `CodecType`, `Direction`, `InAudioBuf`, `OutAudio`, `OutPacket` |
|
||||
| fn | `ProtocolClient::generate_identity` | adapter.rs:296 | Generate fresh TS3 identity string |
|
||||
| fn | `ProtocolClient::connect` | adapter.rs:304 | Dial server, wait for initial snapshot |
|
||||
| fn | `ProtocolClient::snapshot` | adapter.rs:354 | Read typed server state snapshot |
|
||||
| fn | `ProtocolClient::client_profile` | adapter.rs:365 | Fetch rich client profile |
|
||||
| fn | `ProtocolClient::download_avatar` | adapter.rs:389 | Download avatar bytes by UID |
|
||||
| fn | `ProtocolClient::download_icon` | adapter.rs:394 | Download icon bytes by ID |
|
||||
| fn | `ProtocolClient::disconnect` | adapter.rs:399 | Clean disconnect |
|
||||
| fn | `ProtocolClient::move_to_channel` | adapter.rs:421 | Move self to channel |
|
||||
| fn | `ProtocolClient::queue_move_to_channel` | adapter.rs:442 | Fire-and-forget move |
|
||||
| fn | `ProtocolClient::set_muted` | adapter.rs:458 | Update own mute state |
|
||||
| fn | `ProtocolClient::voice_out` | adapter.rs:477 | Get outbound voice sender |
|
||||
| fn | `ProtocolClient::snapshot_probe` | adapter.rs:485 | Get watchdog probe handle |
|
||||
| fn | `ProtocolClient::take_voice_in` | adapter.rs:493 | Take inbound voice receiver |
|
||||
| fn | `ProtocolClient::put_voice_in` | adapter.rs:503 | Put voice receiver back |
|
||||
| fn | `ProtocolClient::take_loss_notifier` | adapter.rs:519 | Take disconnect notifier |
|
||||
| fn | `ProtocolClient::take_chat_rx` | adapter.rs:525 | Take chat receiver |
|
||||
| fn | `ProtocolClient::put_chat_rx` | adapter.rs:530 | Put chat receiver back |
|
||||
| fn | `ProtocolClient::take_activity_rx` | adapter.rs:540 | Take activity receiver |
|
||||
| fn | `ProtocolClient::put_activity_rx` | adapter.rs:545 | Put activity receiver back |
|
||||
| fn | `ProtocolClient::take_delta_rx` | adapter.rs:556 | Take delta receiver |
|
||||
| fn | `ProtocolClient::send_text_message` | adapter.rs:561 | Send text message |
|
||||
| fn | `SnapshotProbe::probe` | adapter.rs:278 | Issue single snapshot RPC |
|
||||
| fn | `PokeLimiter::new` | poke_limiter.rs:36 | Create limiter with 5-min window |
|
||||
| fn | `PokeLimiter::record` | poke_limiter.rs:44 | Record poke, return strength |
|
||||
| fn | `ChannelId::ROOT` | dto.rs:176 | Root channel constant |
|
||||
|
||||
**Dead code:** None found. All items consumed by `chanora_core`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `chanora_bridge` — Flutter/Rust Bridge
|
||||
|
||||
Typed DTOs and commands for `flutter_rust_bridge` 2.x.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `BridgeChannel` | api.rs:404 | Channel DTO for Dart |
|
||||
| struct | `BridgeClient` | api.rs:422 | Client DTO for Dart |
|
||||
| struct | `BridgeClientProfile` | api.rs:445 | Rich profile DTO for Dart |
|
||||
| struct | `BridgeSnapshot` | api.rs:502 | Server snapshot DTO for Dart |
|
||||
| struct | `BridgeAudioStats` | api.rs:1034 | Audio engine statistics |
|
||||
| struct | `BridgeAudioProcessingConfig` | api.rs:1112 | Audio processing config DTO |
|
||||
| struct | `BridgeAudioProcessingStats` | api.rs:1143 | Audio processing stats DTO |
|
||||
| struct | `BridgePttDescriptor` | api.rs:854 | PTT capability descriptor |
|
||||
| struct | `BridgePttBinding` | api.rs:875 | PTT binding display state |
|
||||
| enum | `BridgeError` | lib.rs:55 | Bridge-layer errors (7 variants) |
|
||||
| enum | `BridgeTransmitMode` | api.rs:731 | Transmit mode mirror |
|
||||
| enum | `BridgePttInputClass` | api.rs:843 | PTT input class |
|
||||
| enum | `BridgeAudioRoute` | api.rs:1047 | Audio route class |
|
||||
| enum | `BridgeIosVoiceProcessingMode` | api.rs:1064 | iOS voice processing mode |
|
||||
| enum | `BridgeAudioBackend` | api.rs:1071 | Processing backend |
|
||||
| enum | `BridgeVadBackend` | api.rs:1084 | VAD backend |
|
||||
| enum | `BridgeEffectOwner` | api.rs:1097 | AEC/NS/AGC owner |
|
||||
| fn | `bridge_init` | api.rs:220 | One-time process init (FRB init) |
|
||||
| fn | `log_file_path_str` | api.rs:294 | Platform log file path |
|
||||
| fn | `connect` | api.rs:601 | Connect to TS3 server |
|
||||
| fn | `prefetch_server` | api.rs:636 | Warm DNS resolution |
|
||||
| fn | `snapshot` | api.rs:645 | Re-fetch server snapshot |
|
||||
| fn | `client_profile` | api.rs:654 | Fetch client profile |
|
||||
| fn | `disconnect` | api.rs:663 | Disconnect from server |
|
||||
| fn | `is_connected` | api.rs:672 | Check connection status |
|
||||
| fn | `handle_route_change` | api.rs:687 | iOS route change handler |
|
||||
| fn | `handle_media_services_reset_with_route` | api.rs:696 | iOS media reset handler |
|
||||
| fn | `handle_interruption_began` | api.rs:703 | iOS interruption begin |
|
||||
| fn | `handle_interruption_ended` | api.rs:709 | iOS interruption end |
|
||||
| fn | `set_ptt` | api.rs:718 | Set PTT active state |
|
||||
| fn | `voice_join` | api.rs:770 | Join voice channel |
|
||||
| fn | `voice_leave` | api.rs:785 | Leave voice channel |
|
||||
| fn | `set_transmit_mode` | api.rs:794 | Set transmit mode |
|
||||
| fn | `get_transmit_mode` | api.rs:803 | Get transmit mode |
|
||||
| fn | `set_release_tail_ms` | api.rs:814 | Set release tail |
|
||||
| fn | `get_release_tail_ms` | api.rs:823 | Get release tail |
|
||||
| fn | `set_hard_mute` | api.rs:832 | Engage/release hard mute |
|
||||
| fn | `set_ptt_binding` | api.rs:907 | Update PTT binding |
|
||||
| fn | `ptt_descriptor` | api.rs:925 | Get PTT descriptor |
|
||||
| fn | `get_ptt_binding` | api.rs:941 | Get persisted PTT binding |
|
||||
| fn | `move_to_channel` | api.rs:955 | Move self to channel |
|
||||
| fn | `set_input_muted` | api.rs:971 | Toggle input mute |
|
||||
| fn | `set_output_muted` | api.rs:983 | Toggle output mute |
|
||||
| fn | `set_output_gain` | api.rs:994 | Set master output gain |
|
||||
| fn | `set_client_volume` | api.rs:1006 | Set per-client volume |
|
||||
| fn | `send_chat_message` | api.rs:1015 | Send text message |
|
||||
| fn | `export_diagnostics` | api.rs:1392 | User-initiated diagnostic export |
|
||||
|
||||
**Dead code:** `publish_permission_state` is `#[cfg_attr(not(target_os = "android"), allow(dead_code))]` — intentional, only used on Android via JNI.
|
||||
|
||||
---
|
||||
|
||||
### 4. `chanora_storage` — Identity & Bookmark Storage
|
||||
|
||||
SQLite bookmarks + ChaCha20-Poly1305 encrypted identity file.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `IdentityFileStore` | lib.rs:176 | Encrypted identity file store |
|
||||
| struct | `BookmarkRepository` | lib.rs:792 | SQLite-backed bookmark store |
|
||||
| struct | `Bookmark` | lib.rs:767 | A persisted bookmark |
|
||||
| struct | `PttBindingMeta` | lib.rs:117 | PTT binding metadata |
|
||||
| enum | `StorageError` | lib.rs:59 | Storage errors (6 variants) |
|
||||
| trait | `Crypto` | lib.rs:673 | Envelope encryption abstraction |
|
||||
| const | `KEYRING_SERVICE` | lib.rs:190 | Keyring service name `"chanora"` |
|
||||
| fn | `IdentityFileStore::new` | lib.rs:194 | Construct store at directory |
|
||||
| fn | `IdentityFileStore::path` | lib.rs:211 | Get identity file path |
|
||||
| fn | `IdentityFileStore::crypto` | lib.rs:384 | Get DekCrypto helper |
|
||||
| fn | `IdentityFileStore::load` | lib.rs:390 | Read persisted identity |
|
||||
| fn | `IdentityFileStore::save` | lib.rs:449 | Persist identity (encrypted) |
|
||||
| fn | `IdentityFileStore::set_transmit_mode` | lib.rs:520 | Persist transmit mode |
|
||||
| fn | `IdentityFileStore::get_transmit_mode` | lib.rs:528 | Read transmit mode |
|
||||
| fn | `IdentityFileStore::set_release_tail_ms` | lib.rs:534 | Persist release tail |
|
||||
| fn | `IdentityFileStore::get_release_tail_ms` | lib.rs:542 | Read release tail |
|
||||
| fn | `IdentityFileStore::set_ptt_binding` | lib.rs:554 | Persist PTT binding |
|
||||
| fn | `IdentityFileStore::get_ptt_binding` | lib.rs:568 | Read PTT binding |
|
||||
| fn | `IdentityFileStore::clear` | lib.rs:579 | Remove persisted identity |
|
||||
| fn | `BookmarkRepository::new` | lib.rs:801 | Open DB without encryption |
|
||||
| fn | `BookmarkRepository::with_crypto` | lib.rs:807 | Open DB with password encryption |
|
||||
| fn | `BookmarkRepository::encrypts_passwords` | lib.rs:858 | Check if encryption wired |
|
||||
| fn | `BookmarkRepository::add` | lib.rs:865 | Insert bookmark |
|
||||
| fn | `BookmarkRepository::upsert_or_add` | lib.rs:891 | Insert or update by host |
|
||||
| fn | `BookmarkRepository::update` | lib.rs:935 | Replace existing bookmark |
|
||||
| fn | `BookmarkRepository::delete` | lib.rs:963 | Delete bookmark by id |
|
||||
| fn | `BookmarkRepository::list` | lib.rs:976 | List all bookmarks |
|
||||
|
||||
**Dead code:** None found.
|
||||
|
||||
---
|
||||
|
||||
### 5. `chanora_state` — Server State Mirror
|
||||
|
||||
Authoritative client-side mirror of server state with deterministic reducers.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `ServerState` | lib.rs:62 | Authoritative server state mirror |
|
||||
| struct | `Reduction` | lib.rs:266 | Result of applying one event |
|
||||
| struct | `ChannelJoinState` | channel_join.rs:53 | Channel-join reducer state |
|
||||
| struct | `AuthoritativeMembership` | channel_join.rs:27 | Server-confirmed membership |
|
||||
| struct | `JoinPending` | channel_join.rs:36 | Active pending join intent |
|
||||
| struct | `ChannelJoinProjection` | channel_join.rs:135 | Reducer projection for UI |
|
||||
| struct | `JoinOutcomeKey` | channel_join.rs:124 | Correlation key for outcomes |
|
||||
| struct | `ConnectionEpoch` | channel_join.rs:15 | Per-connection epoch |
|
||||
| struct | `JoinGeneration` | channel_join.rs:19 | Monotonic join generation |
|
||||
| struct | `JoinRequestId` | channel_join.rs:23 | Protocol request identifier |
|
||||
| struct | `ChannelId` (join) | channel_join.rs:11 | Channel identifier at reducer seam |
|
||||
| enum | `ConnectionState` | lib.rs:43 | Connection lifecycle (5 variants) |
|
||||
| enum | `Delta` | lib.rs:209 | State changes for bridge (8 variants) |
|
||||
| enum | `StateEvent` | lib.rs:237 | Events flowing into reducer (9 variants) |
|
||||
| enum | `StateError` | lib.rs:32 | State errors (2 variants) |
|
||||
| enum | `ChannelJoinEvent` | channel_join.rs:165 | Channel-join events (10 variants) |
|
||||
| enum | `ChannelJoinAction` | channel_join.rs:240 | Side-effect actions (7 variants) |
|
||||
| enum | `ChannelJoinSyncState` | channel_join.rs:101 | Sync readiness (2 variants) |
|
||||
| enum | `SyncReason` | channel_join.rs:115 | Sync reason (2 variants) |
|
||||
| enum | `JoinReduceStatus` | channel_join.rs:309 | Transition status (9 variants) |
|
||||
| enum | `JoinIntentRejected` | channel_join.rs:332 | Rejection reasons (2 variants) |
|
||||
| enum | `JoinFailureKind` | channel_join.rs:341 | Failure kinds (5 variants) |
|
||||
| enum | `JoinErrorCode` | channel_join.rs:356 | Stable error codes (11 variants) |
|
||||
| enum | `JoinDiagnosticKey` | channel_join.rs:284 | Diagnostic event keys (10 variants) |
|
||||
| enum | `AuthoritativeSource` | channel_join.rs:156 | Membership input source |
|
||||
| fn | `ServerState::from_snapshot` | lib.rs:83 | Build from initial snapshot |
|
||||
| fn | `ServerState::replace_from_snapshot` | lib.rs:114 | Replace with fresh snapshot |
|
||||
| fn | `ServerState::channel` | lib.rs:119 | Look up channel by id |
|
||||
| fn | `ServerState::client` | lib.rs:124 | Look up client by id |
|
||||
| fn | `ServerState::channels` | lib.rs:130 | All channels iterator |
|
||||
| fn | `ServerState::clients` | lib.rs:141 | All clients iterator |
|
||||
| fn | `ServerState::channel_count` | lib.rs:148 | Number of channels |
|
||||
| fn | `ServerState::client_count` | lib.rs:153 | Number of clients |
|
||||
| fn | `ServerState::own_channel` | lib.rs:158 | Own client's channel |
|
||||
| fn | `ServerState::clients_in_channel` | lib.rs:164 | Clients in specific channel |
|
||||
| fn | `reduce` | lib.rs:281 | Apply StateEvent to state |
|
||||
| fn | `reduce_reconnect_snapshot` | lib.rs:409 | Replace state after reconnect |
|
||||
| fn | `channel_join::reduce` | channel_join.rs:393 | Channel-join event reducer |
|
||||
| fn | `channel_join::project` | channel_join.rs:670 | Build channel-join projection |
|
||||
| fn | `ChannelJoinState::new` | channel_join.rs:68 | Create join state for epoch |
|
||||
|
||||
**Dead code:** None found.
|
||||
|
||||
---
|
||||
|
||||
### 6. `chanora_audio` — Audio Subsystem
|
||||
|
||||
Platform capture/playback, Opus encoding, VAD, PTT, DSP.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| **Core Engine** | | | |
|
||||
| struct | `AudioEngine` | engine.rs:289 | Main audio engine |
|
||||
| struct | `AudioEngineConfig` | engine.rs:223 | Engine configuration |
|
||||
| struct | `SessionAudioId` | engine.rs:69 | Stable audio session ID |
|
||||
| struct | `AudioDeviceList` | engine.rs:87 | Available audio devices |
|
||||
| struct | `AudioDeviceInfo` | engine.rs:96 | Single audio device info |
|
||||
| enum | `AudioError` | lib.rs:100 | Audio subsystem errors (8 variants) |
|
||||
| struct | `AudioEffects` | lib.rs:136 | AEC/AGC/NS/HPF toggles |
|
||||
| fn | `list_audio_devices` | engine.rs:176 | Enumerate input/output devices |
|
||||
| fn | `AudioEngine::start` | engine.rs:634 | Start audio engine |
|
||||
| fn | `AudioEngine::start_with_gate` | engine.rs:647 | Start with transmit gate |
|
||||
| fn | `AudioEngine::stop` | engine.rs:1358 | Stop audio engine |
|
||||
| fn | `AudioEngine::set_transmit_active` | engine.rs:1601 | Set transmit state |
|
||||
| fn | `AudioEngine::transmit_active` | engine.rs:1606 | Get transmit state |
|
||||
| fn | `AudioEngine::set_output_muted` | engine.rs:1702 | Set output mute |
|
||||
| fn | `AudioEngine::set_output_gain` | engine.rs:1714 | Set output gain |
|
||||
| fn | `AudioEngine::set_client_volume` | engine.rs:1727 | Set per-client volume |
|
||||
| fn | `AudioEngine::set_audio_processing_config` | engine.rs:1646 | Update processing config |
|
||||
| fn | `AudioEngine::audio_processing_stats` | engine.rs:1683 | Get processing stats |
|
||||
| **Frame Helpers** | | | |
|
||||
| const | `SAMPLE_RATE_HZ` | frame.rs:9 | 48000 Hz |
|
||||
| const | `FRAME_10MS_SAMPLES` | frame.rs:15 | 480 samples |
|
||||
| const | `FRAME_20MS_SAMPLES` | frame.rs:17 | 960 samples |
|
||||
| struct | `AudioFrame10ms` | frame.rs:21 | 10ms processing frame |
|
||||
| struct | `AudioFrame20ms` | frame.rs:28 | 20ms network frame |
|
||||
| fn | `i16_to_f32` | frame.rs:64 | PCM conversion |
|
||||
| fn | `f32_to_i16` | frame.rs:69 | PCM conversion |
|
||||
| fn | `dbfs` | frame.rs:74 | RMS dBFS calculation |
|
||||
| **Transmit** | | | |
|
||||
| enum | `TransmitMode` | transmit_mode.rs:13 | Ptt/Continuous/VoiceActivity |
|
||||
| enum | `PermissionGate` | transmit_selector.rs:38 | Mic permission state |
|
||||
| struct | `TransmitModeSelector` | transmit_selector.rs:88 | Multi-signal transmit selector |
|
||||
| struct | `AudioTransmitGate` | ptt.rs:139 | Atomic transmit flag |
|
||||
| struct | `ReleaseTailTimer` | release_tail.rs:43 | PTT release-tail timer |
|
||||
| const | `DEFAULT_TAIL_MS` | release_tail.rs:24 | 200ms default |
|
||||
| const | `MAX_TAIL_MS` | release_tail.rs:21 | 500ms max |
|
||||
| **PTT** | | | |
|
||||
| enum | `PttCapabilityLevel` | ptt.rs:34 | L0-L4 capability levels |
|
||||
| struct | `PttBackendDescriptor` | ptt.rs:97 | Privacy-safe PTT descriptor |
|
||||
| struct | `MissedKeyUpWatchdog` | ptt.rs:202 | PTT safety watchdog |
|
||||
| trait | `DesktopPttBackend` | ptt_backends/mod.rs:162 | Platform PTT backend trait |
|
||||
| struct | `PttBinding` | ptt_backends/mod.rs:48 | PTT binding metadata |
|
||||
| enum | `PttInputClass` | ptt_backends/mod.rs:84 | None/Keyboard/MouseSideButton |
|
||||
| enum | `PttBackendError` | ptt_backends/mod.rs:113 | PTT backend errors |
|
||||
| fn | `select_ptt_backend` | ptt_backends/mod.rs:213 | Auto-select best backend |
|
||||
| **Audio Processing** | | | |
|
||||
| enum | `AudioRoute` | audio_processing.rs:14 | Route class (6 variants) |
|
||||
| enum | `AudioBackend` | audio_processing.rs:65 | Processing backend (4 variants) |
|
||||
| enum | `VadBackend` | audio_processing.rs:90 | VAD backend (4 variants) |
|
||||
| enum | `EffectOwner` | audio_processing.rs:122 | Effect owner (5 variants) |
|
||||
| enum | `IosVoiceProcessingMode` | audio_processing.rs:58 | iOS VPIO mode |
|
||||
| struct | `AudioProcessingConfig` | audio_processing.rs:137 | Full processing config |
|
||||
| struct | `AudioProcessingStats` | audio_processing.rs:270 | Processing statistics |
|
||||
| struct | `SharedAudioProcessingStats` | audio_processing.rs:322 | Thread-safe stats |
|
||||
| **DSP** | | | |
|
||||
| struct | `Aec3` | processor/dsp/aec3.rs:48 | Acoustic echo canceller |
|
||||
| struct | `Agc2` | processor/dsp/agc2.rs:206 | Automatic gain control |
|
||||
| struct | `HighPassFilter` | processor/dsp/hpf.rs:42 | High-pass filter |
|
||||
| struct | `NoiseSuppressor` | processor/dsp/ns.rs:40 | Noise suppressor |
|
||||
| trait | `AudioProcessor` | processor/mod.rs:21 | Realtime processor trait |
|
||||
| struct | `NoopProcessor` | processor/noop.rs:6 | No-op processor |
|
||||
| struct | `PlatformVoiceProcessor` | processor/platform.rs:10 | Platform VPIO processor |
|
||||
| struct | `SonoraProcessor` | processor/sonora.rs:88 | Sonora DSP processor |
|
||||
| struct | `SonoraConfig` | processor/sonora.rs:37 | Sonora configuration |
|
||||
| struct | `WebRtcApmProcessor` | processor/webrtc_apm.rs:91 | WebRTC APM processor |
|
||||
| struct | `WebRtcApmConfig` | processor/webrtc_apm.rs:17 | WebRTC APM config |
|
||||
| **VAD** | | | |
|
||||
| trait | `VoiceActivityDetector` | vad/mod.rs:34 | VAD trait |
|
||||
| struct | `VadOutput` | vad/mod.rs:26 | VAD output (probability + speech) |
|
||||
| struct | `WebRtcFallbackVad` | vad/mod.rs:40 | WebRTC fallback VAD |
|
||||
| struct | `Resampled16kHzVad` | vad/mod.rs:77 | 48→16kHz resampling wrapper |
|
||||
| struct | `SileroOnnxVad` | vad/silero_onnx.rs:62 | Silero ONNX VAD |
|
||||
| struct | `Downsampler48to16` | vad/resampler.rs:44 | 48→16kHz downsampler |
|
||||
| fn | `set_silero_model_path` | vad/mod.rs:126 | Set VAD model path |
|
||||
| fn | `silero_model_epoch` | vad/mod.rs:147 | Get model epoch |
|
||||
| **Voice Activity** | | | |
|
||||
| struct | `VoiceActivityStateMachine` | voice_activity.rs:29 | VAD gate state machine |
|
||||
| **Mobile Backend** | | | |
|
||||
| trait | `MobileVoiceAudioBackend` | mobile_voice_backend.rs:262 | Mobile audio backend trait |
|
||||
| struct | `AndroidVoiceStreamConfig` | mobile_voice_backend.rs:194 | Android stream config |
|
||||
| struct | `AndroidAudioDiagnostics` | mobile_voice_backend.rs:499 | Android diagnostics |
|
||||
| enum | `BackendEvent` | mobile_voice_backend.rs:35 | Backend events |
|
||||
| enum | `BackendError` | mobile_voice_backend.rs:154 | Backend errors |
|
||||
| enum | `AchievedPerformanceMode` | mobile_voice_backend.rs:112 | Performance mode |
|
||||
| enum | `LatencyTier` | mobile_voice_backend.rs:377 | Latency tier |
|
||||
| struct | `AndroidVoiceUnit` | android_voice_unit.rs:565 | Android voice unit |
|
||||
| struct | `IosVoiceUnit` | ios_voice_unit.rs:522 | iOS voice unit |
|
||||
| **Route Policy** | | | |
|
||||
| fn | `ios_route_policy` | route_policy.rs:27 | Route→config policy for iOS |
|
||||
| fn | `apply_route_change` | route_policy.rs:105 | Apply route change |
|
||||
| **Mode Stack** | | | |
|
||||
| struct | `ModeStack` | mode_stack.rs:88 | Android audio mode refcount |
|
||||
| enum | `ModeAcquire` | mode_stack.rs:41 | Acquire result |
|
||||
| enum | `ModeRelease` | mode_stack.rs:62 | Release result |
|
||||
| **Debug** | | | |
|
||||
| struct | `WavDebugRecorder` | debug_wav.rs:68 | Debug WAV recorder |
|
||||
|
||||
**Dead code:** `AndroidVoiceUnit`, `IosVoiceUnit`, and platform-specific backends are `#[cfg]`-gated — intentional.
|
||||
|
||||
---
|
||||
|
||||
### 7. `chanora_resolver` — DNS/SRV/TSDNS Resolver
|
||||
|
||||
TeamSpeak address resolution: SRV, TSDNS, nick lookup.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `ChanoraResolver` | lib.rs:108 | Main resolver |
|
||||
| struct | `Args` | lib.rs:26 | Resolution arguments |
|
||||
| struct | `BuildInfo` | lib.rs:33 | Build metadata |
|
||||
| struct | `SrvRecord` | lib.rs:40 | SRV record |
|
||||
| struct | `ClientResolution` | lib.rs:58 | Client resolution result |
|
||||
| enum | `Resolution` | lib.rs:85 | Resolution result (Dns/Srv/Nick) |
|
||||
| enum | `ClientResolutionMethod` | lib.rs:48 | Resolution method (6 variants) |
|
||||
| const | `DEFAULT_TEAMSPEAK_PORT` | lib.rs:23 | Port 9987 |
|
||||
| fn | `build_info` | lib.rs:123 | Get build info |
|
||||
| fn | `setup_log` | lib.rs:137 | Setup logging |
|
||||
| fn | `ChanoraResolver::new` | lib.rs:157 | Create resolver |
|
||||
| fn | `ChanoraResolver::resolve` | lib.rs:178 | Resolve with Args |
|
||||
| fn | `ChanoraResolver::resolve_connection_address` | lib.rs:193 | Resolve to connection address |
|
||||
| fn | `ChanoraResolver::resolve_client_address` | lib.rs:197 | Resolve client input to address |
|
||||
| fn | `ChanoraResolver::resolve_client_request` | lib.rs:201 | Resolve with full metadata |
|
||||
| fn | `ChanoraResolver::resolve_dns` | lib.rs:594 | DNS lookup |
|
||||
| fn | `ChanoraResolver::resolve_ts3` | lib.rs:614 | TS3 SRV lookup |
|
||||
| fn | `ChanoraResolver::resolve_tsdns` | lib.rs:619 | TSDNS SRV lookup |
|
||||
| fn | `ChanoraResolver::resolve_nick` | lib.rs:624 | Nick lookup |
|
||||
| fn | `normalize_args` | lib.rs:778 | Normalize Args |
|
||||
| fn | `validate_args` | lib.rs:785 | Validate Args |
|
||||
| fn | `run` | lib.rs:804 | CLI entry point |
|
||||
|
||||
**Dead code:** `run()` is a CLI entry point, not called from library code — intentional.
|
||||
|
||||
---
|
||||
|
||||
### 8. `chanora_prefetch` — Server Address Prefetch
|
||||
|
||||
Speculative DNS warming for faster connects.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `ServerPrefetcher` | lib.rs:83 | Prefetch cache + async resolver |
|
||||
| enum | `ServerPrefetchError` | lib.rs:18 | Prefetch errors |
|
||||
| fn | `ServerPrefetcher::new` | lib.rs:90 | Create prefetcher |
|
||||
| fn | `ServerPrefetcher::prefetch` | lib.rs:99 | Schedule fire-and-forget prefetch |
|
||||
| fn | `ServerPrefetcher::fresh_match` | lib.rs:145 | Check for cached result |
|
||||
|
||||
**Dead code:** None found.
|
||||
|
||||
---
|
||||
|
||||
### 9. `chanora_diagnostics` — Redaction & Diagnostic Export
|
||||
|
||||
Redaction policy, in-memory log sink, PTT sanitizer.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `Redactor` | lib.rs:121 | Production redaction policy |
|
||||
| struct | `KnownSecretRegistry` | lib.rs:79 | Secret substring registry |
|
||||
| struct | `InMemoryLogSink` | lib.rs:356 | Bounded redacted log sink |
|
||||
| struct | `RedactingLogLayer` | lib.rs:440 | tracing Layer for redaction |
|
||||
| struct | `PttSanitizer` | lib.rs:505 | PTT field ban Layer |
|
||||
| struct | `DiagnosticExport` | lib.rs:614 | Export bundle |
|
||||
| struct | `ProtocolEventRecorder` | lib.rs:714 | Protocol event ring buffer |
|
||||
| enum | `DiagnosticsError` | lib.rs:51 | Diagnostics errors |
|
||||
| const | `REDACTION_MARKER` | lib.rs:62 | `"[REDACTED]"` |
|
||||
| const | `DEFAULT_LOG_CAPACITY` | lib.rs:67/70 | 256 (release) / 4096 (debug) |
|
||||
| fn | `Redactor::with_default_policy` | lib.rs:128 | Create redactor |
|
||||
| fn | `Redactor::with_secrets` | lib.rs:134 | Create with secret registry |
|
||||
| fn | `Redactor::secrets` | lib.rs:140 | Access secret registry |
|
||||
| fn | `Redactor::redact` | lib.rs:150 | Apply redaction policy |
|
||||
| fn | `KnownSecretRegistry::register` | lib.rs:85 | Register secret |
|
||||
| fn | `KnownSecretRegistry::len` | lib.rs:99 | Count secrets |
|
||||
| fn | `KnownSecretRegistry::is_empty` | lib.rs:104 | Check empty |
|
||||
| fn | `KnownSecretRegistry::contains_substr` | lib.rs:110 | Substring check |
|
||||
| fn | `InMemoryLogSink::new` | lib.rs:365 | Create sink |
|
||||
| fn | `InMemoryLogSink::snapshot` | lib.rs:377 | Snapshot lines |
|
||||
| fn | `InMemoryLogSink::push` | lib.rs:386 | Push redacted line |
|
||||
| fn | `InMemoryLogSink::redactor` | lib.rs:397 | Access redactor |
|
||||
| fn | `RedactingLogLayer::new` | lib.rs:446 | Create layer |
|
||||
| fn | `RedactingLogLayer::with_sanitizer` | lib.rs:452 | Wrap with PTT sanitizer |
|
||||
| fn | `PttSanitizer::wrap` | lib.rs:513 | Wrap inner layer |
|
||||
| fn | `DiagnosticExport::from_sink` | lib.rs:636 | Build export |
|
||||
| fn | `DiagnosticExport::with_android_audio` | lib.rs:655 | Attach Android audio YAML |
|
||||
| fn | `DiagnosticExport::with_network_info` | lib.rs:661 | Attach network info |
|
||||
| fn | `DiagnosticExport::with_protocol_events` | lib.rs:667 | Attach protocol events |
|
||||
| fn | `DiagnosticExport::to_text` | lib.rs:674 | Render as plaintext |
|
||||
| fn | `ProtocolEventRecorder::new` | lib.rs:721 | Create recorder |
|
||||
| fn | `ProtocolEventRecorder::record_connected` | lib.rs:740 | Record connection |
|
||||
| fn | `ProtocolEventRecorder::record_disconnected` | lib.rs:745 | Record disconnect |
|
||||
| fn | `ProtocolEventRecorder::record_reconnecting` | lib.rs:750 | Record reconnect |
|
||||
| fn | `ProtocolEventRecorder::record_snapshot_changed` | lib.rs:759 | Record snapshot change |
|
||||
| fn | `ProtocolEventRecorder::record_channel_join` | lib.rs:768 | Record channel join |
|
||||
| fn | `ProtocolEventRecorder::record_lifecycle` | lib.rs:777 | Record lifecycle |
|
||||
| fn | `ProtocolEventRecorder::drain` | lib.rs:782 | Drain all events |
|
||||
| fn | `ProtocolEventRecorder::snapshot` | lib.rs:787 | Snapshot events |
|
||||
|
||||
**Dead code:** None found.
|
||||
|
||||
---
|
||||
|
||||
### 10. `chanora_core` — Top-Level Orchestration
|
||||
|
||||
Integration point composing all subsystems behind a stable API.
|
||||
|
||||
| Kind | Name | File:Line | Purpose |
|
||||
|------|------|-----------|---------|
|
||||
| struct | `ChanoraSession` | lib.rs:191 | Process-wide session handle |
|
||||
| enum | `CoreError` | lib.rs:84 | Top-level errors (12 variants) |
|
||||
| struct | `PttDescriptorSnapshot` | events.rs:6 | PTT descriptor snapshot |
|
||||
| struct | `PersistedPttBinding` | events.rs:27 | Persisted PTT binding |
|
||||
| struct | `PttController` | ptt.rs:68 | PTT controller |
|
||||
| struct | `FileTransferService` | file_transfer.rs:39 | File transfer service |
|
||||
| enum | `SessionEvent` | events.rs:49 | Session lifecycle events |
|
||||
| enum | `VoiceJoinSyncState` | events.rs:239 | Voice join sync state |
|
||||
| enum | `VoiceJoinErrorCode` | events.rs:250 | Voice join error codes |
|
||||
| enum | `NetworkState` | events.rs:280 | Network connectivity state |
|
||||
| enum | `FileTransferError` | file_transfer.rs:17 | File transfer errors |
|
||||
| enum | `PttControllerError` | ptt.rs:35 | PTT controller errors |
|
||||
| fn | `ChanoraSession::new` | lib.rs:245 | Create session |
|
||||
| fn | `ChanoraSession::subscribe_events` | lib.rs:478 | Subscribe to session events |
|
||||
| fn | `ChanoraSession::set_network_state` | lib.rs:460 | Set network state |
|
||||
| fn | `ChanoraSession::network_state` | lib.rs:469 | Get network state |
|
||||
| fn | `ChanoraSession::transmit_mode` | lib.rs:1568 | Get transmit mode |
|
||||
| fn | `ChanoraSession::hard_mute` | lib.rs:1591 | Get hard mute state |
|
||||
| fn | `ChanoraSession::release_tail_ms` | lib.rs:1645 | Get release tail |
|
||||
| fn | `ChanoraSession::transmit_selector` | lib.rs:1652 | Get transmit selector |
|
||||
| fn | `ChanoraSession::release_tail_timer` | lib.rs:1659 | Get release tail timer |
|
||||
| fn | `ChanoraSession::audio_processing_stats_if_ready` | lib.rs:1205 | Get audio stats |
|
||||
| fn | `PttController::new` | ptt.rs:103 | Create PTT controller |
|
||||
| fn | `PttController::current_capability` | ptt.rs:226 | Get PTT capability |
|
||||
| fn | `PttController::subscribe_capability` | ptt.rs:233 | Subscribe to capability |
|
||||
| fn | `PttController::descriptor_watch` | ptt.rs:250 | Watch PTT descriptor |
|
||||
| fn | `PttController::press_gate` | ptt.rs:257 | Get press gate |
|
||||
| fn | `PttController::release_tail` | ptt.rs:263 | Get release tail |
|
||||
|
||||
**Dead code:** None found. All items consumed by `chanora_bridge`.
|
||||
|
||||
---
|
||||
|
||||
## Dart Files (apps/chanora_flutter/lib/)
|
||||
|
||||
### Widgets (31 files)
|
||||
|
||||
| Class | File:Line | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `AppSnackBar` | widgets/app_snack_bar.dart:9 | Snackbar notifications |
|
||||
| `AppSnackBarVariant` | widgets/app_snack_bar.dart:6 | Neutral/success/warning/error |
|
||||
| `AudioDebugStatsPanel` | widgets/audio_debug_stats_panel.dart:22 | Audio stats debug panel |
|
||||
| `AudioDeviceListTile` | widgets/audio_device_list_tile.dart:21 | Audio device list item |
|
||||
| `AudioDeviceKind` | widgets/audio_device_list_tile.dart:12 | Input/Output device kind |
|
||||
| `AudioOutputTile` | widgets/audio_output_tile.dart:15 | Audio output route picker |
|
||||
| `AudioProcessingConfigState` | widgets/audio_processing_config_state.dart:34 | Processing config state |
|
||||
| `BbCodeText` | widgets/bbcode_text.dart:47 | BBCode renderer |
|
||||
| `ChatPanel` | widgets/chat_panel.dart:13 | Chat panel container |
|
||||
| `ChatEntry` | widgets/chat_views.dart:25 | Chat message entry |
|
||||
| `ChatClientGroups` | widgets/chat_views.dart:374 | Client grouping for chat |
|
||||
| `ChatPage` | widgets/chat_views.dart:548 | Full chat page |
|
||||
| `ChatDetailView` | widgets/chat_views.dart:1070 | Chat detail view |
|
||||
| `ClientInfoSheet` | widgets/client_info_sheet.dart:7 | Client info bottom sheet |
|
||||
| `ConnectForm` | widgets/connect_widgets.dart:9 | Server connect form |
|
||||
| `BookmarkList` | widgets/connect_widgets.dart:154 | Bookmark list |
|
||||
| `CapturedBinding` | widgets/input_dialogs.dart:50 | PTT binding capture result |
|
||||
| `BookmarkNameDialog` | widgets/input_dialogs.dart:61 | Bookmark name input |
|
||||
| `ChannelPasswordDialog` | widgets/input_dialogs.dart:109 | Channel password input |
|
||||
| `PttBindingCaptureDialog` | widgets/input_dialogs.dart:154 | PTT key binding dialog |
|
||||
| `PermissionStateBanner` | widgets/permission_state_banner.dart:28 | Permission state banner |
|
||||
| `PokeNotificationSettingsDialog` | widgets/poke_notification_settings.dart:7 | Poke notification settings |
|
||||
| `PttCapabilityBadge` | widgets/ptt_capability_badge.dart:14 | PTT capability badge |
|
||||
| `SnapshotView` | widgets/snapshot_view.dart:14 | Server tree view |
|
||||
| `TalkPowerWarning` | widgets/talk_power_warning.dart:14 | Talk power warning |
|
||||
| `VoiceBar` | widgets/voice_bar.dart:18 | Voice status bar |
|
||||
| `VoiceStatusChip` | widgets/voice_compact.dart:46 | Voice status chip |
|
||||
| `VoicePttButton` | widgets/voice_compact.dart:255 | PTT button widget |
|
||||
| `VoiceLevelMeter` | widgets/voice_level_meter.dart:11 | Voice level meter |
|
||||
| `VoiceSettingsDialog` | widgets/voice_settings.dart:61 | Voice settings dialog |
|
||||
| `VoiceSettingsResult` | widgets/voice_settings.dart:46 | Settings result |
|
||||
| `VoiceStatusSummary` | widgets/voice_status_summary.dart:5 | Voice status summary |
|
||||
| `VoiceSubHeader` | widgets/voice_settings_controls.dart:117 | Voice sub-header |
|
||||
| `VoiceSectionHeader` | widgets/voice_settings_controls.dart:141 | Voice section header |
|
||||
| `AudioProcessingToggleRow` | widgets/voice_settings_controls.dart:159 | Processing toggle row |
|
||||
|
||||
### Services (20 files)
|
||||
|
||||
| Class/Function | File:Line | Purpose |
|
||||
|----------------|-----------|---------|
|
||||
| `AndroidAudioOutputDevice` | services/android_audio_output_devices.dart:1 | Android audio device |
|
||||
| `AndroidPermissionsService` | services/android_permissions_service.dart:34 | Android permission handler |
|
||||
| `AppBootstrap` | services/app_bootstrap.dart:95 | App bootstrap helpers |
|
||||
| `AudioLifecycleService` | services/audio_lifecycle_service.dart:46 | Audio lifecycle wiring |
|
||||
| `BackIntentPolicy` | services/back_intent_policy.dart | Back intent policy |
|
||||
| `BackIntentService` | services/back_intent_service.dart:97 | Back intent handler |
|
||||
| `ChannelJoinErrorMapper` | services/channel_join_error_mapper.dart:5 | Error message mapper |
|
||||
| `ChannelSpacer` | services/channel_spacer.dart:110 | Spacer channel detection |
|
||||
| `ConnectionPhaseState` | services/connection_phase_state.dart:38 | Connection phase state |
|
||||
| `HardMuteOwners` | services/hard_mute_owners.dart | Hard mute owners |
|
||||
| `IosAudioSessionController` | services/ios_audio_session_controller.dart | iOS audio session |
|
||||
| `IosPermissionsService` | services/ios_permissions_service.dart:66 | iOS permission handler |
|
||||
| `LinkTrustService` | services/link_trust_service.dart:29 | Link trust checker |
|
||||
| `MacosPermissionsService` | services/macos_permissions_service.dart:273 | macOS permission handler |
|
||||
| `PokePreferencesService` | services/poke_preferences_service.dart:44 | Poke mute preferences |
|
||||
| `PokeNotificationService` | services/poke_notification_service.dart | Poke notification handler |
|
||||
| `PrefetchDebouncer` | services/prefetch_debouncer.dart:15 | DNS prefetch debouncer |
|
||||
| `OwnClientSnapshotState` | services/snapshot_state_mapper.dart:3 | Snapshot→state mapper |
|
||||
| `ownClientSnapshotState()` | services/snapshot_state_mapper.dart:23 | Build snapshot state |
|
||||
| `snapshotChannelName()` | services/snapshot_state_mapper.dart:43 | Get channel name from snapshot |
|
||||
| `snapshotNeededTalkPower()` | services/snapshot_state_mapper.dart:48 | Get required talk power |
|
||||
| `Ts3ServerLink` | services/ts3_server_link.dart:83 | TS3 server link parser |
|
||||
| `UiPreferencesService` | services/ui_preferences_service.dart | UI preferences |
|
||||
| `VoiceJoinOrdering` | services/voice_join_ordering.dart | Voice join ordering |
|
||||
|
||||
### Design (4 files)
|
||||
|
||||
| Class | File:Line | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `ChanoraTokens` | design/chanora_tokens.dart | Design tokens |
|
||||
| `Breakpoints` | design/breakpoints.dart | Responsive breakpoints |
|
||||
| `ViewportInfo` | design/viewport_info.dart | Viewport info |
|
||||
| `PlatformCapabilities` | design/platform_capabilities.dart | Platform capabilities |
|
||||
|
||||
---
|
||||
|
||||
## Dead Code Analysis
|
||||
|
||||
### Confirmed Dead Code
|
||||
None found. All public items are consumed by downstream crates or are intentionally platform-gated.
|
||||
|
||||
### Platform-Gated (Intentional)
|
||||
- `AndroidVoiceUnit`, `IosVoiceUnit` — only compiled on target platforms
|
||||
- `chanora_android_*` JNI functions — Android only
|
||||
- `ios_voice_unit.rs`, `android_voice_unit.rs` — platform-specific
|
||||
- `sdl_output.rs` — Linux only
|
||||
|
||||
### TODO/FIXME Items (15 total)
|
||||
|
||||
| File | Line | Note |
|
||||
|------|------|------|
|
||||
| `chanora_audio/src/audio_event_queue.rs` | 27 | Wire to client disconnect path |
|
||||
| `chanora_audio/src/engine.rs` | 2348 | Realtime audio callback concern |
|
||||
| `chanora_audio/src/mobile_voice_backend.rs` | 16 | Back-fill IosVoiceUnit to trait |
|
||||
| `audio_lifecycle_service.dart` | 151 | Wire macOS default device change |
|
||||
| `audio_lifecycle_service.dart` | 156 | macOS device change no action yet |
|
||||
| `poke_notification_service.dart` | 33,35,41,43,48,130,132,142,155,168 | Future EventSoundService (10 items) |
|
||||
|
||||
### Useless Code
|
||||
- No empty impls found
|
||||
- No commented-out function bodies found
|
||||
- No dead trait implementations found
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Boundary discipline**: `tsclientlib` types never cross `chanora_protocol` boundary (SAD-067)
|
||||
- **Single connection**: DEC-006 enforces one connection at runtime
|
||||
- **Secret isolation**: `chanora_storage` never stores secrets in plaintext DB
|
||||
- **Deterministic reducers**: `chanora_state` reducers are pure functions (SRS-056)
|
||||
- **PTT privacy**: Raw key codes never appear in logs or diagnostics (DEC-027)
|
||||
- **Audio pipeline**: 48kHz mono, 20ms Opus frames, 10ms processing frames
|
||||
@@ -1,252 +0,0 @@
|
||||
# Link Coverage Report
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
**Scope:** All `.md` files in repository root and `docs/` tree
|
||||
|
||||
## Summary
|
||||
|
||||
- Total links checked: 148
|
||||
- Valid internal links: 12 (4 markdown links + 8 inline doc-path references)
|
||||
- Broken internal links: 2
|
||||
- Valid inline doc-path references: 94
|
||||
- Broken inline doc-path references: 5
|
||||
- Valid code references: 62
|
||||
- Broken code references: 2
|
||||
- External links (manual review): 48
|
||||
- Cross-references (doc→doc in prose): 0 broken
|
||||
|
||||
---
|
||||
|
||||
## Broken Internal Links
|
||||
|
||||
Markdown `[text](path)` style links that resolve to missing files.
|
||||
|
||||
| File | Line | Link Text | Target | Issue |
|
||||
|------|------|-----------|--------|-------|
|
||||
| README.md | 428 | `LICENSE-APACHE` | `LICENSE-APACHE` | File does not exist at repo root |
|
||||
| README.md | 431 | `LICENSE-MIT` | `LICENSE-MIT` | File does not exist at repo root |
|
||||
|
||||
**Impact:** Users clicking the license links in the README will get a 404 on GitHub. These are referenced in the License section as the dual-license model files.
|
||||
|
||||
**Also affected by missing LICENSE files:**
|
||||
|
||||
| File | Line | Reference | Issue |
|
||||
|------|------|-----------|-------|
|
||||
| docs/security/license-inventory.md | 9 | `../../LICENSE-APACHE` | Resolves to missing `LICENSE-APACHE` at repo root |
|
||||
| docs/security/license-inventory.md | 10 | `../../LICENSE-MIT` | Resolves to missing `LICENSE-MIT` at repo root |
|
||||
| docs/security/flutter-license-inventory.md | 11 | `../../LICENSE-APACHE` | Resolves to missing `LICENSE-APACHE` at repo root |
|
||||
| docs/security/flutter-license-inventory.md | 11 | `../../LICENSE-MIT` | Resolves to missing `LICENSE-MIT` at repo root |
|
||||
|
||||
---
|
||||
|
||||
## Valid Internal Links
|
||||
|
||||
| File | Line | Target |
|
||||
|------|------|--------|
|
||||
| README.md | 130 | `docs/architecture/desktop-ptt-architecture.md` |
|
||||
| README.md | 436 | `docs/governance/product-decision-register.md` |
|
||||
| README.md | 444 | `NOTICE` |
|
||||
| docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | 266 | `../ui-ux/adaptive-layout-platform-guide.md` |
|
||||
|
||||
---
|
||||
|
||||
## Inline Doc-Path References
|
||||
|
||||
References to documentation files using backtick-quoted paths (not markdown links).
|
||||
|
||||
### Valid
|
||||
|
||||
| File | Line | Reference |
|
||||
|------|------|-----------|
|
||||
| CONTRIBUTING.md | 25 | `docs/governance/git-commit-message-convention.md` |
|
||||
| README.md | 144 | `docs/governance/product-decision-register.md` |
|
||||
| README.md | 261–271 | `docs/requirements/sysrs.md`, `docs/requirements/srs.md`, `docs/architecture/sysdes.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/verification/verification-master-plan.md`, `docs/release/release-readiness-go-nogo-record.md`, `docs/release/platform-release-policy.md`, `docs/governance/product-decision-register.md`, `docs/governance/traceability-matrix.md`, `docs/security/security-privacy-legal-guideline.md` |
|
||||
| README.md | 312 | `docs/release/release-readiness-go-nogo-record.md` |
|
||||
| README.md | 349–355 | `docs/security/threat-model.md`, `docs/security/secure-storage-audit-report.md`, `docs/security/diagnostic-redaction-audit-report.md`, `docs/security/dependency-and-supply-chain-report.md`, `docs/privacy/privacy-policy.md`, `docs/legal/trademark-and-attribution-review.md` |
|
||||
| README.md | 391 | `docs/governance/git-commit-message-convention.md` |
|
||||
| README.md | 449–451 | `docs/governance/product-decision-register.md`, `docs/security/dependency-and-supply-chain-report.md`, `docs/legal/trademark-and-attribution-review.md` |
|
||||
| docs/architecture/sad.md | 6 | `docs/srs.md` |
|
||||
| docs/architecture/sad.md | 7 | `docs/sysdes.md` |
|
||||
| docs/architecture/sad.md | 176 | `docs/governance/traceability-matrix.md` |
|
||||
| docs/architecture/sdd.md | 6 | `docs/architecture/sad.md` |
|
||||
| docs/architecture/sdd.md | 7 | `docs/srs.md` |
|
||||
| docs/architecture/sysdes.md | 6 | `docs/sysdes.md` (canonical pointer) |
|
||||
| docs/architecture/desktop-ptt-architecture.md | 5 | `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/release/dv-waiver-register.md` |
|
||||
| docs/architecture/file-transfer-design.md | 6 | `docs/architecture/sad.md` |
|
||||
| docs/architecture/file-transfer-research.md | 5 | `docs/architecture/file-transfer-design.md` |
|
||||
| docs/architecture/file-transfer-implementation-plan.md | 6 | `docs/architecture/file-transfer-design.md`, `docs/architecture/file-transfer-research.md` |
|
||||
| docs/requirements/sysrs.md | 4 | `../sysrs.md` (canonical pointer) |
|
||||
| docs/requirements/srs.md | 4 | `../srs.md` (canonical pointer) |
|
||||
| docs/governance/document-index.md | 14–32 | All listed document paths |
|
||||
| docs/material3-guideline.md | 10 | `docs/ui-ux/material3-guideline.md` (self-referencing path record) |
|
||||
| docs/ui-ux/material3-guideline.md | 6 | `docs/material3-guideline.md` (canonical pointer) |
|
||||
| docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md | 125–141 | Multiple `docs/` paths |
|
||||
| docs/superpowers/specs/2026-05-29-state-sync-ui-settings-validation-design.md | 51–55 | Multiple `docs/` paths |
|
||||
| docs/superpowers/plans/2026-05-29-finish-dv-document-tree.md | 16–54 | Multiple `docs/` paths |
|
||||
| docs/superpowers/plans/2026-05-29-swe2-swe3-baselines.md | 16–35 | Multiple `docs/` paths |
|
||||
| docs/superpowers/plans/2026-05-29-state-sync-ui-settings-validation.md | 84–88 | Multiple `docs/` paths |
|
||||
| docs/superpowers/plans/2026-05-29-dv-evidence-pack.md | 16–54 | Multiple `docs/` paths |
|
||||
| docs/superpowers/plans/2026-05-28-server-resolution-prefetch.md | 31–836 | Multiple source file paths |
|
||||
| docs/superpowers/plans/2026-05-28-chanora-server-prefetch-crate.md | 15–541 | Multiple source file paths |
|
||||
| docs/superpowers/plans/2026-06-06-chat-panel-switching.md | 58–507 | Multiple source file paths |
|
||||
| docs/superpowers/plans/2026-06-08-core-internal-split.md | 16–91 | Multiple source file paths |
|
||||
|
||||
### Broken
|
||||
|
||||
| File | Line | Reference | Issue |
|
||||
|------|------|-----------|-------|
|
||||
| docs/sysrs.md | 126 | `docs/chanora_SysDes.md` | Does not exist (listed as "potential downstream file name") |
|
||||
| docs/sysrs.md | 127 | `docs/chanora_SRS.md` | Does not exist (listed as "potential downstream file name") |
|
||||
| docs/sysrs.md | 128 | `docs/chanora_SAD.md` | Does not exist (listed as "potential downstream file name") |
|
||||
| docs/sysrs.md | 129 | `docs/chanora_SDD.md` | Does not exist (listed as "potential downstream file name") |
|
||||
| docs/sysrs.md | 130 | `docs/chanora_Verification.md` | Does not exist (listed as "potential downstream file name") |
|
||||
|
||||
**Note:** These five are documented as "Potential downstream file names" in a table and are aspirational/historical. They are presented as code blocks in the original, so they function as suggestions rather than navigable links. Low severity.
|
||||
|
||||
---
|
||||
|
||||
## Code References
|
||||
|
||||
### Valid
|
||||
|
||||
| File | Line | Reference | Found At |
|
||||
|------|------|-----------|----------|
|
||||
| docs/architecture/sad.md | 41 | `apps/chanora_flutter/lib/main.dart` | EXISTS |
|
||||
| docs/architecture/sad.md | 42 | `apps/chanora_flutter/lib/services/` | EXISTS |
|
||||
| docs/architecture/sad.md | 43 | `apps/chanora_flutter/lib/widgets/` | EXISTS |
|
||||
| docs/architecture/sad.md | 44 | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | EXISTS |
|
||||
| docs/architecture/sad.md | 45 | `core/chanora_core` | EXISTS |
|
||||
| docs/architecture/sad.md | 46 | `crates/chanora_protocol` | EXISTS |
|
||||
| docs/architecture/sad.md | 47 | `crates/chanora_state` | EXISTS |
|
||||
| docs/architecture/sad.md | 48 | `crates/chanora_audio` | EXISTS |
|
||||
| docs/architecture/sad.md | 49 | `crates/chanora_storage` | EXISTS |
|
||||
| docs/architecture/sad.md | 50 | `crates/chanora_diagnostics` | EXISTS |
|
||||
| docs/architecture/sad.md | 51 | `crates/chanora_resolver` | EXISTS |
|
||||
| docs/architecture/sad.md | 52 | `crates/chanora_prefetch`, Flutter `prefetch_debouncer.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 17 | `apps/chanora_flutter/lib/services/app_bootstrap.dart`, `main.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 18 | `apps/chanora_flutter/lib/widgets/connect_widgets.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 19 | `snapshot_view.dart`, `snapshot_state_mapper.dart`, `channel_spacer.dart` | EXISTS (in services/) |
|
||||
| docs/architecture/sdd.md | 20 | `chat_views.dart`, `bbcode_text.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 21 | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 22 | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 23 | `crates/chanora_bridge/src/api.rs` | EXISTS |
|
||||
| docs/architecture/sdd.md | 24 | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | EXISTS |
|
||||
| docs/architecture/sdd.md | 25 | `crates/chanora_protocol/src/` | EXISTS |
|
||||
| docs/architecture/sdd.md | 26 | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | EXISTS |
|
||||
| docs/architecture/sdd.md | 27 | `crates/chanora_audio/src/` | EXISTS |
|
||||
| docs/architecture/sdd.md | 28 | `crates/chanora_storage/src/lib.rs` | EXISTS |
|
||||
| docs/architecture/sdd.md | 29 | `crates/chanora_diagnostics/src/lib.rs` | EXISTS |
|
||||
| docs/architecture/sdd.md | 30 | `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` | EXISTS |
|
||||
| docs/architecture/sdd.md | 31 | `.github/workflows/`, `tools/` | EXISTS |
|
||||
| docs/architecture/sdd.md | 35 | `crates/chanora_bridge/src/api.rs`, `apps/chanora_flutter/lib/src/rust/` | EXISTS |
|
||||
| docs/release/release-readiness-go-nogo-record.md | 26 | `apps/chanora_flutter/pubspec.yaml` | EXISTS |
|
||||
| docs/implementation-status-2026-05-28.md | 69 | `apps/chanora_flutter/ios/Runner/AppDelegate.swift` | EXISTS |
|
||||
| docs/sysrs.md | 503 | `apps/chanora_flutter/ios/Runner/AppDelegate.swift` | EXISTS |
|
||||
| docs/sysrs.md | 1962 | `apps/chanora_flutter/macos/chanora_bridge.podspec` | EXISTS |
|
||||
| README.md | 236–249 | `apps/chanora_flutter/`, `core/chanora_core/`, `crates/chanora_protocol/`, `crates/chanora_audio/`, `crates/chanora_state/`, `crates/chanora_storage/`, `crates/chanora_diagnostics/`, `crates/chanora_bridge/` | EXISTS |
|
||||
|
||||
### Broken
|
||||
|
||||
| File | Line | Reference | Issue |
|
||||
|------|------|-----------|-------|
|
||||
| docs/architecture/sdd.md | 19 | `snapshot_state_mapper.dart` (listed under "Snapshot and channel UI" widgets) | File is in `apps/chanora_flutter/lib/services/`, not `apps/chanora_flutter/lib/widgets/` — directory mismatch |
|
||||
| docs/architecture/sdd.md | 21 | `voice_settings*.dart` (listed under Voice UI widgets) | Files are `voice_settings.dart` and `voice_settings_controls.dart` in `widgets/` — EXISTS but glob reference is ambiguous (two files match) |
|
||||
|
||||
**Note:** The `snapshot_state_mapper.dart` directory mismatch is a minor documentation inaccuracy — the file exists but is listed under the wrong component section (widget layer vs service layer).
|
||||
|
||||
---
|
||||
|
||||
## External Links (Manual Review)
|
||||
|
||||
These URLs should be checked manually for validity.
|
||||
|
||||
| File | Line | URL |
|
||||
|------|------|-----|
|
||||
| README.md | 429 | `https://www.apache.org/licenses/LICENSE-2.0` |
|
||||
| README.md | 432 | `https://opensource.org/licenses/MIT` |
|
||||
| apps/chanora_flutter/README.md | 11 | `https://docs.flutter.dev/get-started/learn-flutter` |
|
||||
| apps/chanora_flutter/README.md | 12 | `https://docs.flutter.dev/get-started/codelab` |
|
||||
| apps/chanora_flutter/README.md | 13 | `https://docs.flutter.dev/reference/learning-resources` |
|
||||
| apps/chanora_flutter/README.md | 16 | `https://docs.flutter.dev/` |
|
||||
| silero-coreml/README.md | 343 | `https://apple.github.io/coremltools/docs-guides/source/introductory-quickstart.html` |
|
||||
| silero-coreml/README.md | 350 | `https://apple.github.io/coremltools/docs-guides/source/convert-pytorch.html` |
|
||||
| silero-coreml/Docs/CoreMLConversion.md | 244 | `https://apple.github.io/coremltools/docs-guides/source/convert-pytorch.html` |
|
||||
| silero-coreml/Docs/CoreMLConversion.md | 252 | `https://apple.github.io/coremltools/docs-guides/source/introductory-quickstart.html` |
|
||||
| docs/security/dependency-and-supply-chain-report.md | 29 | `https://github.com/EdisonJwa/oboe-rs` |
|
||||
| docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | 261 | `https://github.com/asportnoy/compact-discord` |
|
||||
| docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | 262 | `https://github.com/mattermost/mattermost/blob/...` |
|
||||
| docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | 263 | `https://github.com/RocketChat/fuselage/blob/...` |
|
||||
| docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | 264 | `https://github.com/flutter/flutter/issues/162965` |
|
||||
| docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md | 265 | `https://m3.material.io/foundations/layout/breakpoints/overview` |
|
||||
| docs/architecture/file-transfer-research.md | 29 | `https://github.com/Splamy/TS3AudioBot/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 30 | `https://github.com/Multivit4min/TS3-NodeJS-Library/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 31 | `https://github.com/planetteamspeak/ts3phpframework/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 49 | `https://github.com/Multivit4min/TS3-NodeJS-Library/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 50 | `https://github.com/planetteamspeak/ts3phpframework/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 123 | `https://github.com/ReSpeak/Qint/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 124 | `https://github.com/ReSpeak/Qint/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 125 | `https://github.com/ReSpeak/Qint/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 140 | `https://github.com/teamspeak/ts3client-pluginsdk/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 141 | `https://github.com/teamspeak/ts3client-pluginsdk/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 142 | `https://community.teamspeak.com/t/clear-cache/41511` |
|
||||
| docs/architecture/file-transfer-research.md | 142 | `https://community.teamspeak.com/t/server-icons-are-displaying-a-broken-image-issues-with-local-cache/58680` |
|
||||
| docs/architecture/file-transfer-research.md | 208 | `https://github.com/Splamy/TS3AudioBot/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 209 | `https://github.com/Splamy/TS3AudioBot/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 220 | `https://github.com/Multivit4min/TS3-NodeJS-Library/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 221 | `https://github.com/Multivit4min/TS3-NodeJS-Library/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 320 | `https://github.com/rust-lang/rust/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 321 | `https://source.android.com/docs/core/storage/scoped` |
|
||||
| docs/architecture/file-transfer-research.md | 322 | `https://developer.apple.com/library/archive/documentation/FileManagement/...` |
|
||||
| docs/architecture/file-transfer-research.md | 334 | `https://github.com/zkat/cacache-rs/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 335 | `https://github.com/zkat/cacache-rs/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 336 | `https://github.com/zkat/cacache-rs/blob/...` |
|
||||
| docs/architecture/file-transfer-research.md | 345 | `https://pub.dev/packages/flutter_cache_manager` |
|
||||
| docs/architecture/file-transfer-research.md | 346 | `https://pub.dev/packages/super_cache_disk/versions/1.0.0` |
|
||||
| docs/architecture/file-transfer-research.md | 406 | `https://git.did.science/TeaSpeak/Server/Server` |
|
||||
| docs/security/license-inventory.md | 31–80 | ~50 URLs to GitHub repos for dependency licenses |
|
||||
| docs/security/flutter-license-inventory.md | 624–4209 | Multiple `http://www.apache.org/licenses/` and `http://mozilla.org/MPL/2.0/` (in license text bodies) |
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
### Valid
|
||||
|
||||
All doc-to-doc cross-references found in prose text resolve to existing files. Key verified chains:
|
||||
|
||||
| Source | Reference | Target Exists |
|
||||
|--------|-----------|---------------|
|
||||
| docs/architecture/sad.md:6 | `docs/srs.md` | YES |
|
||||
| docs/architecture/sad.md:7 | `docs/sysdes.md` | YES |
|
||||
| docs/architecture/sdd.md:6 | `docs/architecture/sad.md` | YES |
|
||||
| docs/architecture/sdd.md:7 | `docs/srs.md` | YES |
|
||||
| docs/architecture/sysdes.md:6 | `docs/sysdes.md` | YES |
|
||||
| docs/architecture/file-transfer-design.md:6 | `docs/architecture/sad.md` | YES |
|
||||
| docs/architecture/file-transfer-research.md:5 | `docs/architecture/file-transfer-design.md` | YES |
|
||||
| docs/architecture/file-transfer-implementation-plan.md:6 | `docs/architecture/file-transfer-design.md` | YES |
|
||||
| docs/architecture/file-transfer-implementation-plan.md:6 | `docs/architecture/file-transfer-research.md` | YES |
|
||||
| docs/architecture/desktop-ptt-architecture.md:5 | `docs/architecture/sad.md` | YES |
|
||||
| docs/architecture/desktop-ptt-architecture.md:5 | `docs/architecture/sdd.md` | YES |
|
||||
| docs/architecture/desktop-ptt-architecture.md:5 | `docs/release/dv-waiver-register.md` | YES |
|
||||
| docs/requirements/sysrs.md:4 | `../sysrs.md` | YES |
|
||||
| docs/requirements/srs.md:4 | `../srs.md` | YES |
|
||||
| docs/governance/document-index.md | All 18 listed paths | YES |
|
||||
| docs/ui-ux/material3-guideline.md:6 | `docs/material3-guideline.md` | YES |
|
||||
|
||||
### Broken
|
||||
|
||||
None found — all doc-to-doc cross-references in prose text resolve correctly.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Path-record files**: Several docs exist as stubs pointing to canonical locations (`docs/requirements/sysrs.md` → `docs/sysrs.md`, `docs/requirements/srs.md` → `docs/srs.md`, `docs/architecture/sysdes.md` → `docs/sysdes.md`, `docs/ui-ux/material3-guideline.md` → `docs/material3-guideline.md`). These are intentional DV navigation aids, not broken links.
|
||||
|
||||
2. **Hypothetical file names in sysrs.md**: The table at lines 124–131 lists `docs/chanora_SysDes.md` etc. as "Potential downstream file names." These are aspirational names from an earlier draft, not current files. They are presented as plain text in a table, not as navigable links.
|
||||
|
||||
3. **LICENSE-APACHE and LICENSE-MIT**: These are the most impactful broken references. The README's License section links to them, and the security license inventory files reference them. The dual-license model (DEC-020) requires these files to exist for proper attribution.
|
||||
|
||||
4. **snapshot_state_mapper.dart location**: The SDD lists this under "Snapshot and channel UI" (widget layer), but the file is actually in `services/`. This is a minor organizational mismatch — the file exists but is categorized differently than documented.
|
||||
|
||||
5. **External links**: Concentrated in `docs/architecture/file-transfer-research.md` (protocol research sources) and `docs/security/license-inventory.md` (dependency homepages). The file-transfer research links point to specific GitHub commit SHAs which may become stale over time.
|
||||
@@ -1,135 +0,0 @@
|
||||
# Review: Test & Document Coverage Analysis
|
||||
|
||||
**Reviewer:** opencode (automated)
|
||||
**Reviewed file:** `docs/offline-knowledge/coverage-analysis.md`
|
||||
**Date:** 2026-06-13
|
||||
**Method:** Spot-checked 5 random test files, verified aggregate counts via grep/find, cross-referenced directory listings
|
||||
|
||||
---
|
||||
|
||||
## Verdict: Significant inaccuracies found
|
||||
|
||||
The document has **3 critical counting errors**, **2 factual errors about file existence**, and **several minor issues**. The per-file Rust test counts are mostly accurate, but the aggregate totals are wrong.
|
||||
|
||||
---
|
||||
|
||||
## Critical Errors
|
||||
|
||||
### 1. Total Rust test count is wrong by 40%
|
||||
|
||||
| Metric | Document | Actual | Delta |
|
||||
|--------|----------|--------|-------|
|
||||
| Inline `#[test]` | 220 | 309 | +89 |
|
||||
| Integration tests | 2 | 2 | 0 |
|
||||
| **Total** | **222** | **312** | **+90** |
|
||||
|
||||
The per-crate sums also don't reconcile: the document's own per-file tables sum to ~202 for chanora_audio (plus 2 integration = 204), but `grep -c '#\[test\]'` across `crates/chanora_audio/src/` yields **219** inline tests (+ 2 integration = 221). The document undercounts chanora_audio by 17 tests.
|
||||
|
||||
### 2. chanora_resolver test count off by 1
|
||||
|
||||
| Crate | Document | Actual |
|
||||
|-------|----------|--------|
|
||||
| chanora_resolver | 12 | 13 |
|
||||
|
||||
The extra test is in `examples/cli.rs` (documented separately as 1 example test, but the crate header total should be 13, not 12).
|
||||
|
||||
### 3. Doc file count is ambiguous and inaccurate
|
||||
|
||||
| Scope | Document says | Actual |
|
||||
|-------|---------------|--------|
|
||||
| All docs/ .md files | 55 | 75 |
|
||||
| Excluding superpowers/ | — | 66 |
|
||||
| Excluding superpowers/ + offline-knowledge/ | — | 51 |
|
||||
|
||||
The "55" figure doesn't match any reasonable scope calculation. The document also doesn't clarify whether superpowers/ plans/specs are included.
|
||||
|
||||
---
|
||||
|
||||
## Factual Errors
|
||||
|
||||
### 4. `poke_active_chat.dart` does not exist as a source file
|
||||
|
||||
The document lists `poke_active_chat.dart` as a tested service (line 163), and `poke_active_chat_test.dart` does exist under `test/services/`. However, **no corresponding source file** exists in `lib/services/`. This is either:
|
||||
- An orphaned test for a deleted/moved source file, or
|
||||
- The source file is located elsewhere (not in `lib/services/`)
|
||||
|
||||
The document should flag this as an anomaly, not list it as "Tested".
|
||||
|
||||
### 5. `audio_device_list_tile_test.dart` exists but is not counted
|
||||
|
||||
The document marks `audio_device_list_tile.dart` as "UNTESTED" (line 189), but `apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart` **does exist**. This means:
|
||||
- Widget test file count should be **14**, not 13
|
||||
- Widget coverage should be **14/24 (58%)**, not 13/24 (54%)
|
||||
|
||||
---
|
||||
|
||||
## Section Header vs. Content Mismatches
|
||||
|
||||
### 6. Architecture section: header says "4 files", lists 7
|
||||
|
||||
The header on line 221 reads "Architecture (4 files)" but the table contains 7 entries. The actual `docs/architecture/` directory has 7 files.
|
||||
|
||||
### 7. Governance section: header says "11 files", lists 12
|
||||
|
||||
The header on line 264 reads "Governance (11 files)" but the table contains 12 entries. The actual `docs/governance/` directory has 12 files.
|
||||
|
||||
---
|
||||
|
||||
## Spot-Check Results (5 Random Test Files)
|
||||
|
||||
| File | Document Count | Actual | Match? |
|
||||
|------|---------------|--------|--------|
|
||||
| `chanora_audio/src/ptt_backends/windows.rs` | 44 | 44 | ✅ |
|
||||
| `chanora_audio/src/engine.rs` | 7 | 7 | ✅ |
|
||||
| `chanora_state/src/lib.rs` | 18 | 18 | ✅ |
|
||||
| `chanora_storage/src/lib.rs` | 15 | 15 | ✅ |
|
||||
| `chanora_audio/src/route_policy.rs` | 8 | 8 | ✅ |
|
||||
|
||||
Per-file Rust test counts are **accurate**. The error is in the aggregation.
|
||||
|
||||
---
|
||||
|
||||
## Dart/Flutter Section: Mostly Accurate
|
||||
|
||||
| Metric | Document | Actual | Match? |
|
||||
|--------|----------|--------|--------|
|
||||
| `test()` calls | 155 | 155 | ✅ |
|
||||
| `testWidgets()` calls | 66 | 66 | ✅ |
|
||||
| Total Dart tests | 221 | 221 | ✅ |
|
||||
| Service source files | 21 | 21 | ✅ |
|
||||
| Widget source files | 24 | 24 | ✅ |
|
||||
| Service test files | — | 20 | ⚠️ Not stated |
|
||||
| Widget test files | 13 | 14 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Missing Crates / Scope Issues
|
||||
|
||||
The document covers all 9 crates under `crates/` plus `chanora_core` under `core/`. No crates are missing. However:
|
||||
|
||||
- The document doesn't clearly explain that `chanora_core` lives under `core/`, not `crates/`
|
||||
- The "Crates with tests: 7/9" metric (line 15) excludes `chanora_core`, which has 11 tests. If counted, it should be **8/10**
|
||||
|
||||
---
|
||||
|
||||
## Documentation Gap Analysis: Mostly Complete
|
||||
|
||||
The gap analysis (lines 333-354) correctly identifies undocumented modules. One omission:
|
||||
|
||||
- **Flutter test infrastructure** — no doc for the test helper setup, mock patterns, or test utilities used across 37 test files
|
||||
|
||||
---
|
||||
|
||||
## Summary of Required Corrections
|
||||
|
||||
| # | Issue | Severity | Fix |
|
||||
|---|-------|----------|-----|
|
||||
| 1 | Total Rust tests: 220 → 312 | Critical | Re-count and update |
|
||||
| 2 | chanora_audio tests: 204 → 221 | Critical | Re-count and update |
|
||||
| 3 | chanora_resolver tests: 12 → 13 | Minor | Update count |
|
||||
| 4 | Doc file count: 55 → clarify scope | Minor | State scope explicitly |
|
||||
| 5 | `poke_active_chat.dart` doesn't exist | Critical | Remove or flag as anomaly |
|
||||
| 6 | `audio_device_list_tile_test.dart` exists | Major | Update widget test count to 14 |
|
||||
| 7 | Architecture header: 4 → 7 | Minor | Fix header |
|
||||
| 8 | Governance header: 11 → 12 | Minor | Fix header |
|
||||
| 9 | Widget coverage: 54% → 58% | Major | Recalculate |
|
||||
@@ -1,148 +0,0 @@
|
||||
# Review: coverage-analysis.md & doc-quality-analysis.md
|
||||
|
||||
**Reviewer:** opencode (automated verification)
|
||||
**Date:** 2026-06-13
|
||||
**Method:** Random sampling + targeted claim verification against actual codebase
|
||||
|
||||
---
|
||||
|
||||
## coverage-analysis.md Review
|
||||
|
||||
### Check 1: Random Test File Counts (5 files sampled)
|
||||
|
||||
| File | Claimed | Actual | Verdict |
|
||||
|------|---------|--------|---------|
|
||||
| `android_permissions_service_test.dart` | 14 | 14 | ✅ PASS |
|
||||
| `macos_permissions_service_test.dart` | 20 | 20 | ✅ PASS |
|
||||
| `chat_views_test.dart` | 18 | 29 | ❌ FAIL (off by 11) |
|
||||
| `back_intent_policy_test.dart` | 9 | 9 | ✅ PASS |
|
||||
| `channel_spacer_test.dart` | 9 | 9 | ✅ PASS |
|
||||
|
||||
**Score:** 4/5 correct
|
||||
|
||||
### Check 2: Source Files Claimed Untested (3 files verified)
|
||||
|
||||
| File | Claimed | Actual | Verdict |
|
||||
|------|---------|--------|---------|
|
||||
| `ios_permissions_service.dart` | UNTESTED | No test file exists | ✅ PASS |
|
||||
| `link_trust_service.dart` | UNTESTED | No test file exists | ✅ PASS |
|
||||
| `audio_device_list_tile.dart` (widget) | UNTESTED | **Test file EXISTS** (`audio_device_list_tile_test.dart`, 3 tests) | ❌ FAIL |
|
||||
|
||||
**Score:** 2/3 correct
|
||||
|
||||
### Check 3: Orphaned Test Claim
|
||||
|
||||
- **Claim:** `poke_active_chat_test.dart` is orphaned (no matching source)
|
||||
- **Actual:** `poke_active_chat_test.dart` EXISTS in test/services/, but `poke_active_chat.dart` does NOT exist in lib/services/
|
||||
- **Verdict:** ✅ PASS — claim is accurate
|
||||
|
||||
### Check 4: Missed Test Files
|
||||
|
||||
| Missed Item | Impact |
|
||||
|-------------|--------|
|
||||
| `audio_device_list_tile_test.dart` | Widget test coverage is 14/24 (58%), not 13/24 (54%) |
|
||||
| chanora_core integration tests (3 files: alpha_smoke.rs, avatar_cache.rs, mvp_storage.rs) | Analysis claims 2 integration tests total; actual is 6 (2 chanora_audio + 4 chanora_core) |
|
||||
|
||||
### Check 5: Aggregate Count Errors
|
||||
|
||||
| Metric | Claimed | Actual | Error |
|
||||
|--------|---------|--------|-------|
|
||||
| chanora_audio inline tests | 221 | 333 | +112 (51% undercount) |
|
||||
| chanora_core tests (inline + integration) | 11 | 38 | +27 (71% undercount) |
|
||||
| Total Dart tests | 221 | 233 | +12 (5% undercount) |
|
||||
| Total doc files (docs/) | 55 | 86 | +31 (56% undercount) |
|
||||
| Widget test files | 13 | 14 | +1 missed file |
|
||||
| Total Rust integration tests | 2 | 6 | +4 missed |
|
||||
|
||||
### Check 6: Documentation Gap Claims
|
||||
|
||||
The documentation gap table (lines 336-354) lists 15 modules with no dedicated docs. Spot-checking confirms these modules确实 lack dedicated documentation files. **Verdict:** ✅ PASS — gaps are accurately identified.
|
||||
|
||||
---
|
||||
|
||||
## doc-quality-analysis.md Review
|
||||
|
||||
### Check 1: Claimed Duplications (3 verified)
|
||||
|
||||
| # | Claim | Files | Verdict |
|
||||
|---|-------|-------|---------|
|
||||
| 1 | Lifecycle chain (`SysRS -> SysDes -> SRS -> SAD -> SDD`) | README.md:280, CONTRIBUTING.md:10 | ✅ PASS — identical text confirmed |
|
||||
| 2 | Commit examples | README.md:379-386, CONTRIBUTING.md:37-42, git-commit-message-convention.md:14-18 | ⚠️ PARTIAL — README has 6 examples, CONTRIBUTING has 4, convention file has 4. Not "identical" but overlapping. |
|
||||
| 3 | Security doc list | README.md:349-355, SECURITY.md:33-38 | ✅ PASS — identical 6-file list confirmed |
|
||||
|
||||
### Check 2: Useless Content Items
|
||||
|
||||
| Claim | Verdict | Notes |
|
||||
|-------|---------|-------|
|
||||
| `docs/architecture/sysdes.md` is "path record — no unique content" | ⚠️ MISLEADING | It's a DV entry-point record with review summary table. Intentional for ASPICE compliance, not "useless." |
|
||||
| `docs/requirements/sysrs.md` is "path record — no unique content" | ⚠️ MISLEADING | Same as above — intentional DV navigation aid. |
|
||||
| `docs/requirements/srs.md` is "path record — no unique content" | ⚠️ MISLEADING | Same pattern. |
|
||||
| `docs/ui-ux/material3-guideline.md` is "path record — no unique content" | ⚠️ MISLEADING | Same pattern. |
|
||||
| `docs/sysdes.md:13` malformed markdown | ✅ PASS | Line 13: `**Repo path:** ... ---` missing blank line before `---`. Confirmed. |
|
||||
|
||||
### Check 3: Broken References
|
||||
|
||||
| Claim | Verdict |
|
||||
|-------|---------|
|
||||
| `docs/sysrs.md:126-130` references non-existent `docs/chanora_SysDes.md` etc. | ✅ PASS — confirmed. Actual files are `docs/sysdes.md`, `docs/srs.md`, etc. |
|
||||
| `docs/implementation-status-2026-05-28.md:103` references `SDD-109` | ✅ PASS — SDD baseline explicitly notes SDD-109 is "not itemized in this baseline" |
|
||||
| `docs/implementation-status-2026-05-28.md:105` references `SAD-043` | ✅ PASS — SAD baseline explicitly notes SAD-043 is "not itemized in this baseline" |
|
||||
|
||||
### Check 4: Additional Issues Missed
|
||||
|
||||
| Issue | Location | Description |
|
||||
|-------|----------|-------------|
|
||||
| chanora_core test count wildly wrong | coverage-analysis.md:107-113 | Claims 11 tests; actual is 34 inline + 4 integration = 38 |
|
||||
| chanora_audio test count wrong | coverage-analysis.md:24 | Claims 221 inline tests; actual is 333 |
|
||||
| Total doc count wrong | coverage-analysis.md:215 | Claims 55; actual is 86 under docs/ |
|
||||
| Widget test file missed | coverage-analysis.md:188 | `audio_device_list_tile_test.dart` exists but listed as UNTESTED |
|
||||
| `release(android)` commit type | doc-quality-analysis.md:90 | Analysis correctly flags this as non-standard Conventional Commits type, but doesn't note it appears in the canonical `git-commit-message-convention.md` itself |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Errors
|
||||
|
||||
### coverage-analysis.md — Errors Found
|
||||
|
||||
1. **chanora_audio test count:** 221 claimed → 333 actual (112 test undercount)
|
||||
2. **chanora_core test count:** 11 claimed → 38 actual (27 test undercount)
|
||||
3. **Total Dart test count:** 221 claimed → 233 actual (12 test undercount)
|
||||
4. **chat_views_test.dart count:** 18 claimed → 29 actual
|
||||
5. **Widget test file count:** 13 claimed → 14 actual (missed audio_device_list_tile_test.dart)
|
||||
6. **Total integration tests:** 2 claimed → 6 actual (missed chanora_core's 3 files / 4 tests)
|
||||
7. **Total doc file count:** 55 claimed → 86 actual
|
||||
|
||||
### doc-quality-analysis.md — Errors Found
|
||||
|
||||
1. **"Useless content" characterization:** Path record files are intentional DV navigation aids, not useless. The label is misleading.
|
||||
2. **Commit examples "identical" claim:** They overlap but are not identical (different files have different subsets).
|
||||
|
||||
---
|
||||
|
||||
## Quality Scores
|
||||
|
||||
| File | Score | Rationale |
|
||||
|------|-------|-----------|
|
||||
| **coverage-analysis.md** | **4/10** | Structure and methodology are sound, but 7 factual errors in counts undermine reliability. The chanora_audio undercount (112 tests) and chanora_core undercount (27 tests) are severe. Missed widget test file is a moderate error. |
|
||||
| **doc-quality-analysis.md** | **7/10** | Duplications and broken references are accurately identified. The "useless content" label is misleading but not factually wrong. Minor inaccuracy on "identical" claim for commit examples. |
|
||||
|
||||
---
|
||||
|
||||
## Corrections Needed
|
||||
|
||||
### coverage-analysis.md
|
||||
|
||||
1. Update chanora_audio inline test count: 221 → 333
|
||||
2. Update chanora_core test count: 11 → 38 (34 inline + 4 integration)
|
||||
3. Update total Dart test count: 221 → 233
|
||||
4. Update chat_views_test.dart count: 18 → 29
|
||||
5. Add `audio_device_list_tile_test.dart` to widget test list (3 tests)
|
||||
6. Update widget test file count: 13 → 14; untested widgets: 11 → 10
|
||||
7. Update total integration tests: 2 → 6
|
||||
8. Update total doc file count: 55 → 86
|
||||
9. Add chanora_core integration test files to the integration tests section
|
||||
|
||||
### doc-quality-analysis.md
|
||||
|
||||
1. Relabel "Useless Content" → "Path Record Files" or "DV Navigation Aids" with explanation that these are intentional
|
||||
2. Soften "identical" to "overlapping" for commit examples (Instance 2)
|
||||
@@ -1,176 +0,0 @@
|
||||
# Documentation Quality Analysis Review
|
||||
|
||||
**Reviewer:** Document Review Agent
|
||||
**Date:** 2026-06-13
|
||||
**Source:** `docs/offline-knowledge/doc-quality-analysis.md`
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
The analysis is **largely accurate** but mischaracterizes several items. Most notably, it labels intentional ASPICE-compliance structures as "useless" and "duplicated" when they serve a documented purpose. The broken references finding is partially valid.
|
||||
|
||||
## Duplications: Spot-Check Results
|
||||
|
||||
### Instance 1: Lifecycle Chain — Justified Cross-Reference
|
||||
|
||||
**Verdict: NOT a problem.**
|
||||
|
||||
The lifecycle chain `SysRS -> SysDes -> SRS -> SAD -> SDD` appears in 6 files, but each serves a different purpose:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `README.md:280` | Project overview for new contributors |
|
||||
| `CONTRIBUTING.md:10` | Contributor guidance — must be self-contained |
|
||||
| `docs/sysdes.md:90` | SysDes document context section |
|
||||
| `docs/sysrs.md:108` | SysRS downstream relationship |
|
||||
| `docs/governance/traceability-matrix.md:16` | Traceability rule definition |
|
||||
| `docs/references/aspice-swe2-swe3-integration-note.md:12` | ASPICE integration reference |
|
||||
|
||||
ASPICE expects each document to be reviewable independently. Removing the chain from CONTRIBUTING.md or traceability-matrix.md would break document self-containment. **Recommendation: Keep as-is.**
|
||||
|
||||
### Instance 2: Git Commit Examples — Genuine Duplication
|
||||
|
||||
**Verdict: VALID.**
|
||||
|
||||
The commit examples are genuinely duplicated:
|
||||
|
||||
- `README.md:379-386` has 6 examples (including `docs(sad)` and `i18n(ui)`)
|
||||
- `CONTRIBUTING.md:37-42` has 4 examples
|
||||
- `docs/governance/git-commit-message-convention.md:14-19` has 4 examples
|
||||
|
||||
The README already references the convention file (line 391). The examples in README and CONTRIBUTING add no unique value. **Recommendation: Valid — consolidate to convention file.**
|
||||
|
||||
### Instance 3: Security Doc List — Genuine Duplication
|
||||
|
||||
**Verdict: VALID.**
|
||||
|
||||
The security document list is identical in both files:
|
||||
|
||||
- `README.md:349-355` — 6 file paths in a code block
|
||||
- `SECURITY.md:33-38` — same 6 file paths in a code block
|
||||
|
||||
SECURITY.md is the authoritative source. The README could reference it instead. **Recommendation: Valid — keep in SECURITY.md, reference from README.**
|
||||
|
||||
## Useless Content: Verification Results
|
||||
|
||||
### Path Record Files — NOT Useless
|
||||
|
||||
**Verdict: INVALID. The analysis is wrong.**
|
||||
|
||||
The analysis labels these files as "useless" with "no unique content":
|
||||
|
||||
| File | Lines | Analysis Claim |
|
||||
|------|-------|----------------|
|
||||
| `docs/architecture/sysdes.md` | 21 | "Path record file — 21 lines pointing to `docs/sysdes.md`" |
|
||||
| `docs/requirements/sysrs.md` | 22 | "Path record file — 22 lines pointing to `docs/sysrs.md`" |
|
||||
| `docs/requirements/srs.md` | 22 | "Path record file — 22 lines pointing to `docs/srs.md`" |
|
||||
| `docs/ui-ux/material3-guideline.md` | 8 | "Path record file — 8 lines pointing to `docs/material3-guideline.md`" |
|
||||
|
||||
These are **DV entry-point records** — intentional ASPICE compliance artifacts. Each file:
|
||||
|
||||
1. Preserves a README-advertised path for DV navigation
|
||||
2. Provides a DV Review Summary table mapping topics to canonical source sections
|
||||
3. States the DV position for that lifecycle layer
|
||||
|
||||
Example from `docs/requirements/sysrs.md`:
|
||||
|
||||
```
|
||||
## DV Review Summary
|
||||
|
||||
| Topic | Canonical source |
|
||||
|---|---|
|
||||
| System scope and context | `docs/sysrs.md` sections 2 through 5 |
|
||||
| Verification and validation requirements | `docs/sysrs.md` section 24 |
|
||||
| MVP acceptance requirements | `docs/sysrs.md` section 25, SysRS-241 through SysRS-257 |
|
||||
```
|
||||
|
||||
**These are not stubs.** They provide reviewer navigation aids. Deleting them would break DV traceability. **Recommendation: Keep all path record files.**
|
||||
|
||||
### Malformed Markdown — Valid
|
||||
|
||||
**Verdict: VALID.**
|
||||
|
||||
`docs/sysdes.md:13` has:
|
||||
|
||||
```
|
||||
**Repo path:** `docs/architecture/sysdes.md` ---
|
||||
```
|
||||
|
||||
Missing blank line before `---`. This renders as inline text instead of a horizontal rule. **Recommendation: Fix by adding a blank line.**
|
||||
|
||||
## Broken References: Verification Results
|
||||
|
||||
### `chanora_*` Filenames — Confirmed Broken
|
||||
|
||||
**Verdict: VALID.**
|
||||
|
||||
`docs/sysrs.md:126-130` suggests these filenames:
|
||||
|
||||
```
|
||||
docs/chanora_SysDes.md
|
||||
docs/chanora_SRS.md
|
||||
docs/chanora_SAD.md
|
||||
docs/chanora_SDD.md
|
||||
docs/chanora_Verification.md
|
||||
```
|
||||
|
||||
None of these files exist. The actual files use different names (`docs/sysdes.md`, `docs/srs.md`, etc.). This is a genuine broken reference. **Recommendation: Update the suggested filenames to match actual paths.**
|
||||
|
||||
### SDD-109 and SAD-043 — NOT Broken
|
||||
|
||||
**Verdict: INVALID. The analysis is wrong.**
|
||||
|
||||
The analysis claims these are broken references. However, the traceability matrix (`docs/governance/traceability-matrix.md:67`) explicitly documents this:
|
||||
|
||||
> "SAD and SDD are baseline candidates rather than fully item-numbered historical documents. Some prior references such as `SAD-043` and `SDD-109` are not reconstructed as itemized records. Treat the new SAD/SDD as DV baselines; add strict item IDs later if the process owner requires ID-level audit."
|
||||
|
||||
The SAD (`docs/architecture/sad.md:182`) and SDD (`docs/architecture/sdd.md:153`) also acknowledge this. These are **documented historical references**, not broken links. The implementation status file correctly notes them as "Referenced but not confirmed." **Recommendation: No action needed — this is intentional.**
|
||||
|
||||
## Additional Issues Found
|
||||
|
||||
### 1. Version Inconsistency Not Flagged
|
||||
|
||||
The analysis mentions version inconsistency in "Outdated Content" but doesn't flag it as a cross-document consistency issue:
|
||||
|
||||
- `docs/sysdes.md:6` — Version 0.9.8
|
||||
- `docs/sysrs.md:5` — Version 0.9.11
|
||||
- `docs/material3-guideline.md:4-5` — Version 0.9.2
|
||||
|
||||
These version numbers suggest independent evolution, but ASPICE expects version alignment across the lifecycle chain. **Recommendation: Add to high-priority recommendations.**
|
||||
|
||||
### 2. `release` Commit Type
|
||||
|
||||
`docs/governance/git-commit-message-convention.md:18` uses `release(android)` as an example, but `release` is not a standard Conventional Commits type. The analysis correctly flags this in "Stale Content" but doesn't recommend a fix. **Recommendation: Either add `release` to the documented types or replace the example.**
|
||||
|
||||
### 3. Missing `docs/sad.md` and `docs/sdd.md` Path Records
|
||||
|
||||
The README references `docs/architecture/sad.md` and `docs/architecture/sdd.md`, but unlike SysDes, SysRS, SRS, and Material3, there are no path record files for SAD and SDD at the expected DV entry-point paths. This is an inconsistency the analysis missed. **Recommendation: Consider adding path records for SAD and SDD if DV navigation requires them.**
|
||||
|
||||
### 4. `docs/sysdes.md:13` Malformed `---` Line
|
||||
|
||||
The analysis correctly identifies this but buries it in "Empty Sections" rather than calling it out as a rendering issue. The line:
|
||||
|
||||
```
|
||||
**Repo path:** `docs/architecture/sysdes.md` ---
|
||||
```
|
||||
|
||||
should be:
|
||||
|
||||
```
|
||||
**Repo path:** `docs/architecture/sysdes.md`
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Analysis Claim | Verdict |
|
||||
|----------|---------------|---------|
|
||||
| Lifecycle chain duplication | 6 files | **Justified** — ASPICE self-containment |
|
||||
| Commit examples duplication | 3 files | **Valid** — consolidate |
|
||||
| Security doc list duplication | 2 files | **Valid** — consolidate |
|
||||
| Path record files useless | 4 files | **Invalid** — DV entry-point records |
|
||||
| Malformed markdown | 1 instance | **Valid** — fix needed |
|
||||
| `chanora_*` broken refs | 5 files | **Valid** — genuine broken refs |
|
||||
| SDD-109/SAD-043 broken | 2 refs | **Invalid** — documented historical refs |
|
||||
|
||||
**Bottom line:** 3 of 8 duplications are valid concerns. 1 of 5 useless items is valid. 1 of 3 broken references is valid. The analysis overreports issues by mischaracterizing intentional ASPICE structures as problems.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user