Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
370dd37a22 | ||
|
|
523eafd4d7 | ||
|
|
6c00fae1cf | ||
|
|
11a2541042 | ||
|
|
fe3da41e41 | ||
|
|
602eedc029 | ||
|
|
020218a7a1 | ||
|
|
1e774035d1 | ||
|
|
acc1450904 | ||
|
|
72ded4e011 | ||
|
|
c04aaf4a51 | ||
|
|
c4b8732bd7 | ||
|
|
7968f90f7d | ||
|
|
508fa8b408 | ||
|
|
00e3fa7ad5 | ||
|
|
ef19f4e7ce | ||
|
|
7ab50a4eb5 | ||
|
|
0c8efa38f1 | ||
|
|
af823da543 | ||
|
|
b944bd89d7 | ||
|
|
475f6e0603 | ||
|
|
008128defc | ||
|
|
dd6fa6121e | ||
|
|
8dbe767d4f | ||
|
|
f509c370b3 | ||
|
|
2948c029d0 | ||
|
|
40883c40c8 | ||
|
|
1efeaac19d | ||
|
|
6d2405f67a | ||
|
|
01a4a9ed28 | ||
|
|
93f4608250 | ||
|
|
e14dd73570 | ||
|
|
2ab8d5aae2 | ||
|
|
78ecafcc2c | ||
|
|
8c9eba15c3 | ||
|
|
292617f8e8 | ||
|
|
484522cbb6 | ||
|
|
ecdd68eff2 | ||
|
|
b2937da726 | ||
|
|
b20e6b663a | ||
|
|
13c7648a65 | ||
|
|
328776622f | ||
|
|
7dbf461262 | ||
|
|
3b13a7edb4 |
+124
-15
@@ -25,15 +25,10 @@ jobs:
|
||||
run: cargo check --workspace --locked
|
||||
- name: cargo test --workspace
|
||||
env:
|
||||
# Storage tests must not hit the real OS keyring on CI:
|
||||
# there is no D-Bus session available and the call would
|
||||
# block. The runtime code carries the same toggle for
|
||||
# headless / sandboxed environments.
|
||||
CHANORA_DISABLE_KEYRING: "1"
|
||||
run: cargo test --workspace --locked --no-fail-fast
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
continue-on-error: true
|
||||
|
||||
supply-chain:
|
||||
name: cargo deny (licenses + advisories + bans + sources)
|
||||
@@ -43,9 +38,6 @@ jobs:
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check
|
||||
# `licenses` enforces the DEC-020 license posture; the
|
||||
# other three are minimal supply-chain hygiene per
|
||||
# `docs/governance/legal-review-readiness.md` §5.
|
||||
arguments: --workspace --all-features
|
||||
|
||||
license-inventory:
|
||||
@@ -58,10 +50,6 @@ jobs:
|
||||
- name: Install cargo-about
|
||||
run: cargo install --locked --features cli cargo-about
|
||||
- name: Regenerate inventory and compare
|
||||
# Build the inventory in a temp file and diff against the
|
||||
# committed copy. CI fails when the committed inventory is
|
||||
# stale, forcing contributors to run the tool locally
|
||||
# before opening a PR that touches the dependency tree.
|
||||
run: |
|
||||
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
|
||||
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|
||||
@@ -80,9 +68,6 @@ jobs:
|
||||
run: flutter pub get
|
||||
- name: Regenerate Flutter license inventory and compare
|
||||
env:
|
||||
# Resolved by the wrapper from $HOME/sdks/flutter when
|
||||
# not set; CI's subosito/flutter-action puts flutter on
|
||||
# PATH but exports the SDK root under FLUTTER_ROOT.
|
||||
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
|
||||
run: |
|
||||
./tools/dump_flutter_licenses.sh
|
||||
@@ -136,3 +121,127 @@ jobs:
|
||||
if: steps.silero-coreml.outputs.available == 'true'
|
||||
working-directory: apps/chanora_flutter
|
||||
run: flutter build ios --release --no-codesign
|
||||
- name: xcodebuild archive verification
|
||||
if: steps.silero-coreml.outputs.available == 'true'
|
||||
working-directory: apps/chanora_flutter
|
||||
run: |
|
||||
xcodebuild archive \
|
||||
-workspace ios/Runner.xcworkspace \
|
||||
-scheme Runner \
|
||||
-archive build/Runner.xcarchive \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
| xcpretty || { echo "::error::xcodebuild archive failed — see issue-history-analysis.md §4 'Xcode Archive vs build divergence'"; exit 1; }
|
||||
|
||||
android-build:
|
||||
name: Android build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-linux-android
|
||||
abi: arm64-v8a
|
||||
- target: armv7-linux-androideabi
|
||||
abi: armeabi-v7a
|
||||
- target: x86_64-linux-android
|
||||
abi: x86_64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-ndk
|
||||
run: cargo install --locked cargo-ndk
|
||||
- name: Setup NDK
|
||||
run: |
|
||||
ANDROID_ROOT="/usr/local/lib/android/sdk"
|
||||
SDKMANAGER="$ANDROID_ROOT/cmdline-tools/latest/bin/sdkmanager"
|
||||
echo "y" | $SDKMANAGER "ndk;27.0.12077973"
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV"
|
||||
- name: cargo ndk build
|
||||
run: cargo ndk -t ${{ matrix.abi }} build --workspace --locked
|
||||
|
||||
windows-build:
|
||||
name: Windows build
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: cargo check --workspace
|
||||
run: cargo check --workspace --locked
|
||||
|
||||
macos-build:
|
||||
name: macOS build
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: cargo check --workspace
|
||||
run: cargo check --workspace --locked
|
||||
|
||||
linux-multi-distro:
|
||||
name: Linux build (${{ matrix.distro }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: ubuntu
|
||||
image: ubuntu:24.04
|
||||
install: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential pkg-config \
|
||||
libasound2-dev libpulse-dev libdbus-1-dev libsdl2-dev libopus-dev
|
||||
- distro: fedora
|
||||
image: fedora:latest
|
||||
install: |
|
||||
dnf install -y curl gcc pkg-config \
|
||||
alsa-lib-devel pulseaudio-libs-devel dbus-devel SDL2-devel opus-devel
|
||||
- distro: arch
|
||||
image: archlinux:latest
|
||||
install: |
|
||||
pacman -Syu --noconfirm
|
||||
pacman -S --noconfirm curl base-devel pkg-config \
|
||||
alsa-lib pulseaudio dbus sdl2 opus
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install system dependencies
|
||||
run: ${{ matrix.install }}
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: cargo check --workspace
|
||||
run: cargo check --workspace
|
||||
|
||||
coverage:
|
||||
name: cargo llvm-cov
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: System deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libasound2-dev libpulse-dev pkg-config \
|
||||
libdbus-1-dev libsdl2-dev libopus-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-llvm-cov
|
||||
run: cargo install --locked cargo-llvm-cov
|
||||
- name: Generate coverage
|
||||
env:
|
||||
CHANORA_DISABLE_KEYRING: "1"
|
||||
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lcov-report
|
||||
path: lcov.info
|
||||
|
||||
@@ -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`
|
||||
Generated
+2
@@ -663,6 +663,7 @@ dependencies = [
|
||||
"log",
|
||||
"ndk-context",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -727,6 +728,7 @@ dependencies = [
|
||||
"futures",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
|
||||
@@ -40,6 +40,8 @@ members = [
|
||||
|
||||
exclude = [
|
||||
"apps/chanora_flutter",
|
||||
"tools/protocol-probe",
|
||||
"tools/audio-test",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024-2026 Chanora Contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Chanora Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
|
||||
class LiveDiagnosticsDialog extends StatefulWidget {
|
||||
const LiveDiagnosticsDialog({super.key, required this.diagnosticsTextBuilder});
|
||||
|
||||
final String Function() diagnosticsTextBuilder;
|
||||
|
||||
@override
|
||||
State<LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState();
|
||||
}
|
||||
|
||||
class _LiveDiagnosticsDialogState extends State<LiveDiagnosticsDialog> {
|
||||
static const _refreshInterval = Duration(seconds: 1);
|
||||
|
||||
Timer? _refreshTimer;
|
||||
String _text = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
_refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
final next = widget.diagnosticsTextBuilder();
|
||||
if (!mounted || next == _text) return;
|
||||
setState(() => _text = next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(l10n.diagnosticsAction)),
|
||||
const SizedBox(width: 12),
|
||||
Tooltip(
|
||||
message: l10n.diagnosticsLiveUpdating,
|
||||
child: Icon(
|
||||
Icons.sync,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 720,
|
||||
maxHeight: size.height * 0.65,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
_text,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await SharePlus.instance.share(ShareParams(text: _text));
|
||||
},
|
||||
child: Text(l10n.shareAction),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: _text));
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(l10n.copyAction),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,12 +148,13 @@ void wireMacosAudioLifecycle({
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleDefaultDeviceChange':
|
||||
// TODO: call rust.macosDefaultDeviceChanged() once exposed
|
||||
// via flutter_rust_bridge; until then the event is captured
|
||||
// here for observability.
|
||||
// TRACKED(macos-device-change): call rust.macosDefaultDeviceChanged()
|
||||
// once exposed via flutter_rust_bridge; until then the event is
|
||||
// captured here for observability.
|
||||
break;
|
||||
case 'handleConfigurationChange':
|
||||
// TODO: same — currently captured, no engine action yet.
|
||||
// TRACKED(macos-config-change): currently captured, no engine action
|
||||
// yet — depends on Rust-side device-change API exposure.
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -3,11 +3,19 @@ import 'package:flutter/material.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Manages user-trusted link domains to suppress external-link warnings.
|
||||
///
|
||||
/// Trusted domains are persisted in [SharedPreferences] under
|
||||
/// `'trusted_domains'`. Supports wildcard patterns (e.g. `'*.example.com'`)
|
||||
/// that match any subdomain of the base host.
|
||||
///
|
||||
/// This is a singleton; use [LinkTrustService.instance] to obtain it.
|
||||
class LinkTrustService extends ChangeNotifier {
|
||||
static LinkTrustService? _instance;
|
||||
final Set<String> _trusted = {};
|
||||
bool _loaded = false;
|
||||
|
||||
/// Returns the singleton [LinkTrustService] instance.
|
||||
static LinkTrustService get instance {
|
||||
_instance ??= LinkTrustService._();
|
||||
return _instance!;
|
||||
@@ -26,6 +34,10 @@ class LinkTrustService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Returns `true` if [host] matches any trusted domain pattern.
|
||||
///
|
||||
/// Matching is case-insensitive. Wildcard patterns like `'*.example.com'`
|
||||
/// match both `example.com` and any `*.example.com` subdomain.
|
||||
bool isTrusted(String host) {
|
||||
host = host.toLowerCase();
|
||||
for (final pattern in _trusted) {
|
||||
@@ -34,6 +46,7 @@ class LinkTrustService extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Persists [host] as a trusted domain and notifies listeners.
|
||||
Future<void> addTrustedDomain(String host) async {
|
||||
host = host.toLowerCase();
|
||||
_trusted.add(host);
|
||||
@@ -51,6 +64,10 @@ class LinkTrustService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a dialog asking the user whether to open an external link.
|
||||
///
|
||||
/// Returns `true` if the user chose to open and checked "remember this domain",
|
||||
/// `false` if the user chose to open without remembering, or `null` if cancelled.
|
||||
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
bool remember = false;
|
||||
return showDialog<bool>(
|
||||
|
||||
@@ -3,7 +3,20 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Manages local push notifications for TeamSpeak poke events.
|
||||
///
|
||||
/// Handles platform-specific notification configuration across Android,
|
||||
/// iOS, macOS, Linux, and Windows. Notification sound is intentionally
|
||||
/// delegated to [EventSoundService] (tracked: TODO-event-sounds); this
|
||||
/// service only manages the visual notification surface.
|
||||
///
|
||||
/// Poke strength maps to platform-appropriate urgency levels:
|
||||
/// - [BridgePokeStrength.strong] → high-priority / time-sensitive
|
||||
/// - [BridgePokeStrength.suppressed] → normal priority
|
||||
/// - [BridgePokeStrength.suppressedOverflow] → passive / low priority
|
||||
class PokeNotificationService {
|
||||
/// Creates a [PokeNotificationService] with an optional
|
||||
/// [FlutterLocalNotificationsPlugin] for testing.
|
||||
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
|
||||
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
@@ -22,6 +35,9 @@ class PokeNotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notifications;
|
||||
bool _initialized = false;
|
||||
|
||||
/// Initializes the notification plugin with platform-specific settings.
|
||||
///
|
||||
/// Safe to call multiple times; subsequent calls are no-ops.
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
await _notifications.initialize(
|
||||
@@ -30,22 +46,20 @@ class PokeNotificationService {
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
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.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
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.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
defaultSuppressSound: true,
|
||||
),
|
||||
windows: WindowsInitializationSettings(
|
||||
@@ -58,6 +72,10 @@ class PokeNotificationService {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Requests notification permission from the user on the current platform.
|
||||
///
|
||||
/// Returns `true` on platforms where permission is not required (web,
|
||||
/// Linux, Windows) or when the user grants permission.
|
||||
Future<bool> requestPermission() async {
|
||||
await init();
|
||||
if (kIsWeb) return true;
|
||||
@@ -89,6 +107,8 @@ class PokeNotificationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Displays a poke notification from [senderName] with [strength]-based
|
||||
/// urgency. Silently returns if the user has denied notification permission.
|
||||
Future<void> show({
|
||||
required String senderName,
|
||||
required String message,
|
||||
@@ -127,9 +147,8 @@ class PokeNotificationService {
|
||||
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.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
playSound: false,
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
silent: true,
|
||||
groupKey: _groupKey,
|
||||
category: AndroidNotificationCategory.message,
|
||||
@@ -139,7 +158,7 @@ class PokeNotificationService {
|
||||
|
||||
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
||||
return DarwinNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
presentSound: false,
|
||||
threadIdentifier: _darwinThreadId,
|
||||
interruptionLevel: switch (strength) {
|
||||
@@ -152,7 +171,7 @@ class PokeNotificationService {
|
||||
|
||||
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
|
||||
return LinuxNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
suppressSound: true,
|
||||
urgency: switch (strength) {
|
||||
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
|
||||
@@ -165,7 +184,7 @@ class PokeNotificationService {
|
||||
|
||||
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
|
||||
return WindowsNotificationDetails(
|
||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||
audio: WindowsNotificationAudio.silent(),
|
||||
header: _windowsHeader,
|
||||
scenario: strength == rust.BridgePokeStrength.strong
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persists user preferences for TeamSpeak poke notifications.
|
||||
///
|
||||
/// Stores two settings:
|
||||
/// - Whether pokes are globally enabled
|
||||
/// - A set of muted sender client IDs
|
||||
///
|
||||
/// Preferences are written to [SharedPreferences] and observable
|
||||
/// through [ValueListenable] so UI widgets can rebuild reactively.
|
||||
class PokePreferencesService {
|
||||
static const _enabledKey = 'pokes.enabled';
|
||||
static const _mutedSendersKey = 'pokes.muted_senders';
|
||||
@@ -10,9 +18,13 @@ class PokePreferencesService {
|
||||
const <BigInt>{},
|
||||
);
|
||||
|
||||
/// Whether poke notifications are globally enabled.
|
||||
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
||||
|
||||
/// Set of client IDs whose pokes are muted.
|
||||
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
||||
|
||||
/// Loads persisted preferences from [SharedPreferences].
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
|
||||
@@ -21,18 +33,21 @@ class PokePreferencesService {
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Enables or disables poke notifications globally.
|
||||
Future<void> setPokesEnabled(bool enabled) async {
|
||||
_pokesEnabled.value = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_enabledKey, enabled);
|
||||
}
|
||||
|
||||
/// Adds [senderId] to the muted senders set.
|
||||
Future<void> muteSender(BigInt senderId) async {
|
||||
if (_mutedSenders.value.contains(senderId)) return;
|
||||
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
||||
await _saveMutedSenders();
|
||||
}
|
||||
|
||||
/// Removes [senderId] from the muted senders set.
|
||||
Future<void> unmuteSender(BigInt senderId) async {
|
||||
if (!_mutedSenders.value.contains(senderId)) return;
|
||||
_mutedSenders.value = _mutedSenders.value
|
||||
@@ -41,6 +56,7 @@ class PokePreferencesService {
|
||||
await _saveMutedSenders();
|
||||
}
|
||||
|
||||
/// Returns `true` if [senderId] is in the muted senders set.
|
||||
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
|
||||
|
||||
Future<void> _saveMutedSenders() async {
|
||||
@@ -51,6 +67,7 @@ class PokePreferencesService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Releases [ValueNotifier] resources.
|
||||
void dispose() {
|
||||
_pokesEnabled.dispose();
|
||||
_mutedSenders.dispose();
|
||||
|
||||
@@ -18,11 +18,19 @@ class UiSettings {
|
||||
this.host = '',
|
||||
this.nickname = '',
|
||||
this.themeMode = UiThemeMode.system,
|
||||
this.transmitModeIndex,
|
||||
this.releaseTailMs,
|
||||
this.inputDeviceId,
|
||||
this.outputDeviceId,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final String nickname;
|
||||
final UiThemeMode themeMode;
|
||||
final int? transmitModeIndex;
|
||||
final int? releaseTailMs;
|
||||
final String? inputDeviceId;
|
||||
final String? outputDeviceId;
|
||||
}
|
||||
|
||||
class UiPreferencesService {
|
||||
@@ -30,6 +38,10 @@ class UiPreferencesService {
|
||||
static const _nicknameKey = 'ui.nickname';
|
||||
static const _themeModeKey = 'ui.theme_mode';
|
||||
static const _permissionsExplainedKey = 'perms_explained';
|
||||
static const _transmitModeIndexKey = 'voice.transmit_mode_index';
|
||||
static const _releaseTailMsKey = 'voice.release_tail_ms';
|
||||
static const _inputDeviceIdKey = 'audio.input_device_id';
|
||||
static const _outputDeviceIdKey = 'audio.output_device_id';
|
||||
|
||||
const UiPreferencesService();
|
||||
|
||||
@@ -39,6 +51,10 @@ class UiPreferencesService {
|
||||
host: prefs.getString(_hostKey) ?? '',
|
||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
||||
transmitModeIndex: prefs.getInt(_transmitModeIndexKey),
|
||||
releaseTailMs: prefs.getInt(_releaseTailMsKey),
|
||||
inputDeviceId: prefs.getString(_inputDeviceIdKey),
|
||||
outputDeviceId: prefs.getString(_outputDeviceIdKey),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +69,34 @@ class UiPreferencesService {
|
||||
await prefs.setString(_themeModeKey, themeMode.name);
|
||||
}
|
||||
|
||||
Future<void> saveTransmitModeIndex(int index) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_transmitModeIndexKey, index);
|
||||
}
|
||||
|
||||
Future<void> saveReleaseTailMs(int ms) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_releaseTailMsKey, ms);
|
||||
}
|
||||
|
||||
Future<void> saveInputDeviceId(String? id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (id == null) {
|
||||
await prefs.remove(_inputDeviceIdKey);
|
||||
} else {
|
||||
await prefs.setString(_inputDeviceIdKey, id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveOutputDeviceId(String? id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (id == null) {
|
||||
await prefs.remove(_outputDeviceIdKey);
|
||||
} else {
|
||||
await prefs.setString(_outputDeviceIdKey, id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasExplainedPermissions() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
||||
|
||||
@@ -27,6 +27,7 @@ class AudioDeviceListTile extends StatefulWidget {
|
||||
AudioDeviceListLoader? loadDevices,
|
||||
AudioDeviceSetter? setInputDevice,
|
||||
AudioDeviceSetter? setOutputDevice,
|
||||
this.onDeviceChanged,
|
||||
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
||||
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
||||
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
||||
@@ -46,6 +47,10 @@ class AudioDeviceListTile extends StatefulWidget {
|
||||
/// Selects an output device.
|
||||
final AudioDeviceSetter setOutputDevice;
|
||||
|
||||
/// Called after a device selection succeeds. Receives the device id
|
||||
/// (null for system default).
|
||||
final ValueChanged<String?>? onDeviceChanged;
|
||||
|
||||
@override
|
||||
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
||||
}
|
||||
@@ -88,6 +93,7 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
||||
setState(() {
|
||||
_selectedDeviceId = deviceId;
|
||||
});
|
||||
widget.onDeviceChanged?.call(deviceId);
|
||||
|
||||
final selectedName = _selectedDevice?.name ?? 'System default';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -93,78 +93,51 @@ class AudioProcessingConfigState {
|
||||
isLinux: linux,
|
||||
);
|
||||
|
||||
rust.BridgeAudioBackend processingBackend;
|
||||
rust.BridgeEffectOwner aec;
|
||||
rust.BridgeEffectOwner ns;
|
||||
rust.BridgeEffectOwner agc;
|
||||
|
||||
if (android) {
|
||||
final owner = preferHardware
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: vad,
|
||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
if (appleVoiceProcessing) {
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vad,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
if (desktopWebrtcApm) {
|
||||
processingBackend = preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm;
|
||||
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||
} else if (appleVoiceProcessing) {
|
||||
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
|
||||
aec = rust.BridgeEffectOwner.platform;
|
||||
ns = rust.BridgeEffectOwner.platform;
|
||||
agc = rust.BridgeEffectOwner.platform;
|
||||
} else if (desktopWebrtcApm) {
|
||||
final owner = rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: vad,
|
||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
processingBackend = rust.BridgeAudioBackend.webrtcApm;
|
||||
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||
} else {
|
||||
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
|
||||
aec = rust.BridgeEffectOwner.platform;
|
||||
ns = nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off;
|
||||
agc = agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off;
|
||||
}
|
||||
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
processingBackend: processingBackend,
|
||||
vadBackend: vad,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off,
|
||||
agc: agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off,
|
||||
aec: aec,
|
||||
ns: ns,
|
||||
agc: agc,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/poke_notification_service.dart';
|
||||
import '../services/poke_preferences_service.dart';
|
||||
import 'voice_settings_controls.dart';
|
||||
|
||||
class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||
const PokeNotificationSettingsDialog({super.key, required this.preferences});
|
||||
const PokeNotificationSettingsDialog({
|
||||
super.key,
|
||||
required this.preferences,
|
||||
required this.notificationService,
|
||||
});
|
||||
|
||||
final PokePreferencesService preferences;
|
||||
final PokeNotificationService notificationService;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -31,7 +37,15 @@ class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||
title: Text(l10n.pokeSettingsEnableLabel),
|
||||
subtitle: Text(l10n.pokeSettingsEnableDescription),
|
||||
value: enabled,
|
||||
onChanged: (value) => preferences.setPokesEnabled(value),
|
||||
onChanged: (value) async {
|
||||
await preferences.setPokesEnabled(value);
|
||||
// Request notification permission when enabling pokes
|
||||
// so the OS prompt appears immediately rather than on
|
||||
// the first poke event.
|
||||
if (value) {
|
||||
await notificationService.requestPermission();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/channel_spacer.dart';
|
||||
@@ -10,7 +11,14 @@ import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
import 'talk_power_warning.dart';
|
||||
|
||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
||||
/// Connected-server snapshot displaying welcome text, channel tree, and clients.
|
||||
///
|
||||
/// Renders the full channel hierarchy from a [BridgeSnapshot] with expandable
|
||||
/// channel nodes, client voice-status indicators, unread-message badges, and
|
||||
/// context menus for client actions (info, chat, poke, volume).
|
||||
///
|
||||
/// Channel join is triggered by tapping an unlocked channel row; password-
|
||||
/// protected channels invoke [onJoinChannelWithPassword] instead.
|
||||
class SnapshotView extends StatefulWidget {
|
||||
/// Construct a snapshot view.
|
||||
const SnapshotView({
|
||||
@@ -571,11 +579,40 @@ class _ClientVolumePreference {
|
||||
}
|
||||
|
||||
class _ClientVolumePreferences extends ChangeNotifier {
|
||||
_ClientVolumePreferences._();
|
||||
_ClientVolumePreferences._() {
|
||||
_load();
|
||||
}
|
||||
|
||||
static final instance = _ClientVolumePreferences._();
|
||||
|
||||
static const _prefsKey = 'client_volume_prefs';
|
||||
|
||||
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
||||
bool _loaded = false;
|
||||
|
||||
Future<void> _load() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getStringList(_prefsKey) ?? const [];
|
||||
for (final entry in raw) {
|
||||
final parts = entry.split(':');
|
||||
if (parts.length == 3) {
|
||||
final id = BigInt.tryParse(parts[0]);
|
||||
final volume = double.tryParse(parts[1]);
|
||||
final muted = parts[2] == '1';
|
||||
if (id != null && volume != null) {
|
||||
_byClientId[id] = _ClientVolumePreference(
|
||||
volume: volume,
|
||||
muted: muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
||||
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
||||
@@ -588,6 +625,17 @@ class _ClientVolumePreferences extends ChangeNotifier {
|
||||
_byClientId.remove(clientId);
|
||||
}
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = _byClientId.entries
|
||||
.map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}')
|
||||
.toList();
|
||||
await prefs.setStringList(_prefsKey, raw);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,7 +1156,7 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
|
||||
/// Context menu for channel tiles offering "Chat" on right-click or
|
||||
/// long-press. Primary tap passes through to the child for voice join.
|
||||
class _ChannelContextMenu extends StatelessWidget {
|
||||
const _ChannelContextMenu({
|
||||
|
||||
@@ -26,21 +26,6 @@ import 'voice_settings_controls.dart';
|
||||
import 'voice_status_summary.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
bool get _isIos {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
bool get _isMacOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isMacOS;
|
||||
}
|
||||
|
||||
bool get _isDesktopSileroVadHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isWindows || Platform.isLinux;
|
||||
}
|
||||
|
||||
/// Two-line status chip that summarises the current voice state.
|
||||
/// Tap to open the voice details modal.
|
||||
class VoiceStatusChip extends StatelessWidget {
|
||||
@@ -737,130 +722,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Android HW/SW selector.
|
||||
if (Platform.isAndroid) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<bool>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: androidProcessingSegments,
|
||||
selected: {_audioProcessing.preferHardware},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() => _audioProcessing.preferHardware = s.first);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_audioProcessing.preferHardware
|
||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||
: 'Software mode applies the full WebRTC APM stage set.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (_isIos) ...[
|
||||
Text(
|
||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||
'hidden here; only settings that still affect the shipping '
|
||||
'iOS path are shown.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsNsControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Noise suppression',
|
||||
subtitle: 'Wiener filter',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.nsEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsAecControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Echo cancellation',
|
||||
subtitle: Platform.isAndroid
|
||||
? (_audioProcessing.preferHardware
|
||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||
: 'WebRTC AEC3 · adaptive filter')
|
||||
: (_isMacOS
|
||||
? 'Managed by platform VPIO'
|
||||
: 'WebRTC AEC3 · adaptive filter'),
|
||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||||
onChanged: _isMacOS
|
||||
? null
|
||||
: (v) {
|
||||
setState(() => _audioProcessing.aecEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsAgcControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Auto gain control',
|
||||
subtitle: 'AGC2 · -18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.agcEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'High-pass filter',
|
||||
subtitle: '80 Hz · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.hpfEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsLimiterControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.limiterEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
|
||||
// VAD backend.
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Voice activity detection (VAD)',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() => _audioProcessing.vadBackend = s.first);
|
||||
|
||||
AudioProcessingPanel(
|
||||
config: _audioProcessing,
|
||||
dense: true,
|
||||
update: (mutation) {
|
||||
setState(mutation);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,9 +2,17 @@ import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard the user would bind a PTT key on.
|
||||
bool get isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files.
|
||||
// Centralize all platform checks here and update call sites to use these
|
||||
// getters instead of raw Platform.isAndroid/isIOS/etc.
|
||||
bool get _notWeb => !kIsWeb;
|
||||
|
||||
bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid);
|
||||
|
||||
bool get isAndroidHost => _notWeb && Platform.isAndroid;
|
||||
|
||||
bool get isIosHost => _notWeb && Platform.isIOS;
|
||||
|
||||
bool get isMacOsHost => _notWeb && Platform.isMacOS;
|
||||
|
||||
bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux);
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
// - VAD backend
|
||||
// - platform audio-processing mode selection where available
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
@@ -22,27 +19,8 @@ import 'voice_platform.dart';
|
||||
import 'voice_settings_controls.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
bool get _isAndroid {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isAndroid;
|
||||
}
|
||||
|
||||
bool get _isIos {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
bool get _isMacOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isMacOS;
|
||||
}
|
||||
|
||||
bool get _isDesktopSileroVadHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isWindows || Platform.isLinux;
|
||||
}
|
||||
|
||||
/// Result returned by [VoiceSettingsDialog].
|
||||
/// Result returned by [VoiceSettingsDialog] when the user saves or
|
||||
/// requests a key bind.
|
||||
class VoiceSettingsResult {
|
||||
const VoiceSettingsResult({
|
||||
required this.mode,
|
||||
@@ -57,7 +35,15 @@ class VoiceSettingsResult {
|
||||
final rust.BridgeAudioProcessingConfig audioConfig;
|
||||
}
|
||||
|
||||
/// Voice + audio processing settings dialog.
|
||||
/// Dialog for configuring voice transmission and audio processing settings.
|
||||
///
|
||||
/// Surfaces transmit mode selection (continuous, PTT, voice-activity),
|
||||
/// PTT release-tail slider, key-bind request, and a full audio processing
|
||||
/// panel covering noise suppression, echo cancellation, AGC, HPF, and VAD
|
||||
/// backend selection.
|
||||
///
|
||||
/// On mobile hosts, the voice-activity segment is hidden when no
|
||||
/// Chanora-owned VAD pipeline is available (iOS, macOS, web).
|
||||
class VoiceSettingsDialog extends StatefulWidget {
|
||||
const VoiceSettingsDialog({
|
||||
super.key,
|
||||
@@ -70,6 +56,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
this.talkPower,
|
||||
this.neededTalkPower,
|
||||
this.talkPowerGranted,
|
||||
this.onInputDeviceChanged,
|
||||
this.onOutputDeviceChanged,
|
||||
});
|
||||
|
||||
final rust.BridgeTransmitMode initialMode;
|
||||
@@ -81,6 +69,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
final int? talkPower;
|
||||
final int? neededTalkPower;
|
||||
final bool? talkPowerGranted;
|
||||
final ValueChanged<String?>? onInputDeviceChanged;
|
||||
final ValueChanged<String?>? onOutputDeviceChanged;
|
||||
|
||||
@override
|
||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||
@@ -106,7 +96,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
return _audioProcessing.buildConfig(
|
||||
base: widget.initialAudioConfig,
|
||||
isAndroid: _isAndroid,
|
||||
isAndroid: isAndroidHost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,92 +179,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio processing'),
|
||||
|
||||
// Android HW/SW selector
|
||||
if (_isAndroid) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<bool>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: androidProcessingSegments,
|
||||
selected: {_audioProcessing.preferHardware},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.preferHardware = s.first),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_audioProcessing.preferHardware
|
||||
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
|
||||
: 'Android software mode applies the full WebRTC APM control set.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
const VoiceSubHeader('DSP stages'),
|
||||
if (_isIos) ...[
|
||||
Text(
|
||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||
'hidden here; only settings that still affect the shipping '
|
||||
'iOS path are shown.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.nsEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Echo cancellation (AEC3)',
|
||||
subtitle: _isAndroid
|
||||
? (_audioProcessing.preferHardware
|
||||
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
|
||||
: 'WebRTC AEC3 · adaptive filter')
|
||||
: (_isMacOS
|
||||
? 'Managed by platform VPIO'
|
||||
: 'WebRTC AEC3 · adaptive filter'),
|
||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||||
onChanged: _isMacOS
|
||||
? null
|
||||
: (v) => setState(() => _audioProcessing.aecEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · -18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.agcEnabled = v),
|
||||
),
|
||||
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.hpfEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.limiterEnabled = v),
|
||||
),
|
||||
AudioProcessingPanel(
|
||||
config: _audioProcessing,
|
||||
update: (mutation) => setState(mutation),
|
||||
),
|
||||
|
||||
if (isTalkPowerBlocked(
|
||||
talkPower: widget.talkPower,
|
||||
@@ -289,22 +197,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
),
|
||||
],
|
||||
|
||||
// ── VAD ────────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
widget.pttLevel.isNotEmpty) ...[
|
||||
@@ -318,23 +210,25 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
|
||||
// ── Audio output route picker (mobile only) ─────────────
|
||||
if (_isAndroid || _isIos) ...[
|
||||
if (isAndroidHost || isIosHost) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio output'),
|
||||
const AudioOutputTile(),
|
||||
],
|
||||
|
||||
// ── Audio devices (desktop only, SRS-026) ──────────────
|
||||
if (!_isAndroid && !_isIos) ...[
|
||||
if (!isAndroidHost && !isIosHost) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio devices'),
|
||||
const AudioDeviceListTile(
|
||||
AudioDeviceListTile(
|
||||
label: 'Input',
|
||||
kind: AudioDeviceKind.input,
|
||||
onDeviceChanged: widget.onInputDeviceChanged,
|
||||
),
|
||||
const AudioDeviceListTile(
|
||||
AudioDeviceListTile(
|
||||
label: 'Output',
|
||||
kind: AudioDeviceKind.output,
|
||||
onDeviceChanged: widget.onOutputDeviceChanged,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'audio_processing_config_state.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Shared compact style for voice settings segmented buttons.
|
||||
@@ -230,3 +232,173 @@ class AudioProcessingToggleRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef AudioFieldUpdater = void Function(VoidCallback mutation);
|
||||
|
||||
class AudioProcessingPanel extends StatelessWidget {
|
||||
const AudioProcessingPanel({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.update,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
final AudioProcessingConfigState config;
|
||||
final AudioFieldUpdater update;
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isAndroid = isAndroidHost;
|
||||
final isIos = isIosHost;
|
||||
final isMacOS = isMacOsHost;
|
||||
final isDesktopSilero = isDesktopSileroVadHost;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isAndroid) ...[
|
||||
if (!dense) const VoiceSubHeader('Processing backend'),
|
||||
if (dense) _compactLabel(context, 'Processing backend'),
|
||||
SegmentedButton<bool>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: androidProcessingSegments,
|
||||
selected: {config.preferHardware},
|
||||
onSelectionChanged: (s) {
|
||||
update(() => config.preferHardware = s.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
config.preferHardware
|
||||
? (dense
|
||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||
: 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.')
|
||||
: (dense
|
||||
? 'Software mode applies the full WebRTC APM stage set.'
|
||||
: 'Android software mode applies the full WebRTC APM control set.'),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (!dense) const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
if (!dense) const VoiceSubHeader('DSP stages'),
|
||||
|
||||
if (isIos) ...[
|
||||
Text(
|
||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||
'hidden here; only settings that still affect the shipping '
|
||||
'iOS path are shown.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsNsControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'Noise suppression' : 'Noise suppression (NS)',
|
||||
subtitle: dense
|
||||
? 'Wiener filter'
|
||||
: 'Wiener filter · stationary noise',
|
||||
value: config.nsEnabled,
|
||||
onChanged: (v) => update(() => config.nsEnabled = v),
|
||||
),
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsAecControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'Echo cancellation' : 'Echo cancellation (AEC3)',
|
||||
subtitle: _aecSubtitle(isAndroid, isMacOS, config),
|
||||
value: isMacOS ? true : config.aecEnabled,
|
||||
onChanged:
|
||||
isMacOS ? null : (v) => update(() => config.aecEnabled = v),
|
||||
),
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsAgcControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'Auto gain control' : 'Auto gain control (AGC2)',
|
||||
subtitle: dense
|
||||
? 'AGC2 · -18 dBFS target'
|
||||
: 'RNN VAD-gated · -18 dBFS target',
|
||||
value: config.agcEnabled,
|
||||
onChanged: (v) => update(() => config.agcEnabled = v),
|
||||
),
|
||||
if (!isAndroid || androidShowsHpfControl(config))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: dense ? 'High-pass filter' : 'High-pass filter (HPF)',
|
||||
subtitle: dense
|
||||
? '80 Hz · DC removal'
|
||||
: '80 Hz Butterworth · DC removal',
|
||||
value: config.hpfEnabled,
|
||||
onChanged: (v) => update(() => config.hpfEnabled = v),
|
||||
),
|
||||
if (!isIos &&
|
||||
(!isAndroid || androidShowsLimiterControl(config)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: dense,
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: config.limiterEnabled,
|
||||
onChanged: (v) => update(() => config.limiterEnabled = v),
|
||||
),
|
||||
|
||||
if (!dense) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||
const VoiceSubHeader('Backend'),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Voice activity detection (VAD)',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments:
|
||||
isDesktopSilero ? desktopVadBackendSegments : vadBackendSegments,
|
||||
selected: {config.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
update(() => config.vadBackend = s.first);
|
||||
},
|
||||
),
|
||||
if (!dense) const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _aecSubtitle(bool isAndroid, bool isMacOS, AudioProcessingConfigState c) {
|
||||
if (isAndroid) {
|
||||
return c.preferHardware
|
||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||
: 'WebRTC AEC3 · adaptive filter';
|
||||
}
|
||||
if (isMacOS) return 'Managed by platform VPIO';
|
||||
return 'WebRTC AEC3 · adaptive filter';
|
||||
}
|
||||
|
||||
Widget _compactLabel(BuildContext context, String text) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,4 +67,44 @@ void main() {
|
||||
|
||||
expect(settings.themeMode, UiThemeMode.system);
|
||||
});
|
||||
|
||||
test('loads null voice settings when unset', () async {
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
expect(settings.transmitModeIndex, isNull);
|
||||
expect(settings.releaseTailMs, isNull);
|
||||
expect(settings.inputDeviceId, isNull);
|
||||
expect(settings.outputDeviceId, isNull);
|
||||
});
|
||||
|
||||
test('saves and loads transmit mode index', () async {
|
||||
await service.saveTransmitModeIndex(1);
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
expect(settings.transmitModeIndex, 1);
|
||||
});
|
||||
|
||||
test('saves and loads release tail ms', () async {
|
||||
await service.saveReleaseTailMs(300);
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
expect(settings.releaseTailMs, 300);
|
||||
});
|
||||
|
||||
test('saves and loads audio device ids', () async {
|
||||
await service.saveInputDeviceId('input-123');
|
||||
await service.saveOutputDeviceId('output-456');
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
expect(settings.inputDeviceId, 'input-123');
|
||||
expect(settings.outputDeviceId, 'output-456');
|
||||
});
|
||||
|
||||
test('removes audio device id when set to null', () async {
|
||||
await service.saveInputDeviceId('input-123');
|
||||
await service.saveInputDeviceId(null);
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
expect(settings.inputDeviceId, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/connect_widgets.dart';
|
||||
|
||||
void main() {
|
||||
group('ConnectForm', () {
|
||||
late TextEditingController hostCtl;
|
||||
late TextEditingController nickCtl;
|
||||
late TextEditingController passwordCtl;
|
||||
|
||||
Widget buildForm({
|
||||
VoidCallback? onConnect,
|
||||
VoidCallback? onAddBookmark,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ConnectForm(
|
||||
hostCtl: hostCtl,
|
||||
nickCtl: nickCtl,
|
||||
passwordCtl: passwordCtl,
|
||||
onConnect: onConnect ?? () {},
|
||||
onAddBookmark: onAddBookmark ?? () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
hostCtl = TextEditingController();
|
||||
nickCtl = TextEditingController();
|
||||
passwordCtl = TextEditingController();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
hostCtl.dispose();
|
||||
nickCtl.dispose();
|
||||
passwordCtl.dispose();
|
||||
});
|
||||
|
||||
testWidgets('renders all three text fields', (tester) async {
|
||||
await tester.pumpWidget(buildForm());
|
||||
|
||||
expect(find.byType(TextField), findsNWidgets(3));
|
||||
expect(find.byIcon(Icons.dns_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.login), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bookmark_add_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('connect callback fires on button tap', (tester) async {
|
||||
var connected = false;
|
||||
await tester.pumpWidget(buildForm(
|
||||
onConnect: () => connected = true,
|
||||
));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.login));
|
||||
expect(connected, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('bookmark callback fires on button tap', (tester) async {
|
||||
var bookmarked = false;
|
||||
await tester.pumpWidget(buildForm(
|
||||
onAddBookmark: () => bookmarked = true,
|
||||
));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bookmark_add_outlined));
|
||||
expect(bookmarked, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('host field lowercases and strips whitespace', (tester) async {
|
||||
await tester.pumpWidget(buildForm());
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'host[:port]'),
|
||||
' MyServer.COM ',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(hostCtl.text, 'myserver.com');
|
||||
});
|
||||
|
||||
testWidgets('password field is obscured', (tester) async {
|
||||
await tester.pumpWidget(buildForm());
|
||||
|
||||
final passwordField = tester.widgetList<TextField>(
|
||||
find.byType(TextField),
|
||||
).last;
|
||||
expect(passwordField.obscureText, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('form uses outlined border decoration', (tester) async {
|
||||
await tester.pumpWidget(buildForm());
|
||||
|
||||
final fields = tester.widgetList<TextField>(find.byType(TextField));
|
||||
for (final field in fields) {
|
||||
final decoration = field.decoration as InputDecoration;
|
||||
expect(decoration.border, isA<OutlineInputBorder>());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('BookmarkList', () {
|
||||
Widget buildBookmarkList({
|
||||
required List<rust.BridgeBookmark> bookmarks,
|
||||
ValueChanged<rust.BridgeBookmark>? onConnect,
|
||||
ValueChanged<rust.BridgeBookmark>? onDelete,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: BookmarkList(
|
||||
bookmarks: bookmarks,
|
||||
onConnect: onConnect ?? (_) {},
|
||||
onDelete: onDelete ?? (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const testBookmark = rust.BridgeBookmark(
|
||||
id: 1,
|
||||
displayName: 'My Server',
|
||||
host: 'ts.example.com',
|
||||
nickname: 'TestUser',
|
||||
password: '',
|
||||
);
|
||||
|
||||
const testBookmark2 = rust.BridgeBookmark(
|
||||
id: 2,
|
||||
displayName: 'Work Server',
|
||||
host: 'work.ts.com',
|
||||
nickname: 'WorkNick',
|
||||
password: 'secret',
|
||||
);
|
||||
|
||||
testWidgets('shows empty message when no bookmarks', (tester) async {
|
||||
await tester.pumpWidget(buildBookmarkList(bookmarks: const []));
|
||||
|
||||
expect(find.byType(BookmarkList), findsOneWidget);
|
||||
expect(find.byType(Card), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders bookmark cards with name and host', (tester) async {
|
||||
await tester.pumpWidget(buildBookmarkList(
|
||||
bookmarks: const [testBookmark],
|
||||
));
|
||||
|
||||
expect(find.text('My Server'), findsOneWidget);
|
||||
expect(find.text('ts.example.com — TestUser'), findsOneWidget);
|
||||
expect(find.byType(Card), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders multiple bookmarks', (tester) async {
|
||||
await tester.pumpWidget(buildBookmarkList(
|
||||
bookmarks: const [testBookmark, testBookmark2],
|
||||
));
|
||||
|
||||
expect(find.text('My Server'), findsOneWidget);
|
||||
expect(find.text('Work Server'), findsOneWidget);
|
||||
expect(find.byType(Card), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('connect callback fires with correct bookmark', (tester) async {
|
||||
rust.BridgeBookmark? connectedBookmark;
|
||||
await tester.pumpWidget(buildBookmarkList(
|
||||
bookmarks: const [testBookmark],
|
||||
onConnect: (b) => connectedBookmark = b,
|
||||
));
|
||||
|
||||
final connectButtons = find.byIcon(Icons.login);
|
||||
await tester.tap(connectButtons.first);
|
||||
|
||||
expect(connectedBookmark, testBookmark);
|
||||
});
|
||||
|
||||
testWidgets('delete callback fires with correct bookmark', (tester) async {
|
||||
rust.BridgeBookmark? deletedBookmark;
|
||||
await tester.pumpWidget(buildBookmarkList(
|
||||
bookmarks: const [testBookmark],
|
||||
onDelete: (b) => deletedBookmark = b,
|
||||
));
|
||||
|
||||
final deleteButtons = find.byIcon(Icons.delete_outline);
|
||||
await tester.tap(deleteButtons.first);
|
||||
|
||||
expect(deletedBookmark, testBookmark);
|
||||
});
|
||||
|
||||
testWidgets('each bookmark card has connect and delete buttons', (tester) async {
|
||||
await tester.pumpWidget(buildBookmarkList(
|
||||
bookmarks: const [testBookmark],
|
||||
));
|
||||
|
||||
expect(find.byIcon(Icons.login), findsOneWidget);
|
||||
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/services/poke_notification_service.dart';
|
||||
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
|
||||
|
||||
@@ -14,11 +15,16 @@ void main() {
|
||||
await preferences.muteSender(BigInt.from(42));
|
||||
addTearDown(preferences.dispose);
|
||||
|
||||
final notificationService = PokeNotificationService();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: PokeNotificationSettingsDialog(preferences: preferences),
|
||||
home: PokeNotificationSettingsDialog(
|
||||
preferences: preferences,
|
||||
notificationService: notificationService,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/voice_bar.dart';
|
||||
|
||||
void main() {
|
||||
const defaultStats = rust.BridgeAudioStats(
|
||||
framesSent: 100,
|
||||
framesReceived: 200,
|
||||
pttActive: false,
|
||||
inputLevel: -30.0,
|
||||
);
|
||||
|
||||
Widget buildVoiceBar({
|
||||
bool inChannel = true,
|
||||
rust.BridgeTransmitMode transmitMode = rust.BridgeTransmitMode.ptt,
|
||||
bool hardMute = false,
|
||||
bool outputMuted = false,
|
||||
bool talkPowerBlocked = false,
|
||||
int releaseTailMs = 150,
|
||||
String channelName = 'Test Channel',
|
||||
rust.BridgeAudioStats? audioStats = defaultStats,
|
||||
double? inputLevel,
|
||||
String pttLevel = 'L1WindowsHook',
|
||||
String pttBackendId = 'windows-raw-input',
|
||||
String pttBoundInputClass = 'keyboard',
|
||||
String pttBoundKeyLabel = 'Space',
|
||||
VoidCallback? onConfigure,
|
||||
ValueChanged<bool>? onPttHeldChanged,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: VoiceBar(
|
||||
inChannel: inChannel,
|
||||
transmitMode: transmitMode,
|
||||
hardMute: hardMute,
|
||||
outputMuted: outputMuted,
|
||||
talkPowerBlocked: talkPowerBlocked,
|
||||
releaseTailMs: releaseTailMs,
|
||||
channelName: channelName,
|
||||
audioStats: audioStats,
|
||||
inputLevel: inputLevel,
|
||||
pttLevel: pttLevel,
|
||||
pttBackendId: pttBackendId,
|
||||
pttBoundInputClass: pttBoundInputClass,
|
||||
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||
onConfigure: onConfigure ?? () {},
|
||||
onPttHeldChanged: onPttHeldChanged ?? (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('VoiceBar renders in PTT mode with stats', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar());
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
expect(find.byIcon(Icons.radio_button_checked), findsOneWidget);
|
||||
expect(find.byIcon(Icons.tune), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar renders in continuous mode', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
transmitMode: rust.BridgeTransmitMode.continuous,
|
||||
));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
expect(find.byIcon(Icons.podcasts), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar shows channel status when in channel', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(inChannel: true));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar hides channel status when not in channel', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
inChannel: false,
|
||||
channelName: '',
|
||||
));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar shows talk power blocked indicator', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
talkPowerBlocked: true,
|
||||
));
|
||||
|
||||
expect(find.text('Insufficient talk power'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar shows audio stats line when stats available', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar());
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar hides audio stats line when stats null', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
audioStats: null,
|
||||
));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar onConfigure callback fires', (tester) async {
|
||||
var configured = false;
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
onConfigure: () => configured = true,
|
||||
));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
expect(configured, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar renders PTT capability badge in PTT mode', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
transmitMode: rust.BridgeTransmitMode.ptt,
|
||||
pttLevel: 'L1WindowsHook',
|
||||
));
|
||||
|
||||
expect(find.byIcon(Icons.public), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar does not render PTT badge in continuous mode', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
transmitMode: rust.BridgeTransmitMode.continuous,
|
||||
));
|
||||
|
||||
expect(find.byIcon(Icons.public), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar renders with active PTT state', (tester) async {
|
||||
const activeStats = rust.BridgeAudioStats(
|
||||
framesSent: 500,
|
||||
framesReceived: 300,
|
||||
pttActive: true,
|
||||
inputLevel: -10.0,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(buildVoiceBar(audioStats: activeStats));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar renders with input level override', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
inputLevel: -20.0,
|
||||
));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VoiceBar renders with null input level', (tester) async {
|
||||
await tester.pumpWidget(buildVoiceBar(
|
||||
audioStats: null,
|
||||
inputLevel: null,
|
||||
));
|
||||
|
||||
expect(find.byType(VoiceBar), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/voice_settings.dart';
|
||||
|
||||
rust.BridgeAudioProcessingConfig _defaultConfig() {
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.webrtcApm,
|
||||
ns: rust.BridgeEffectOwner.webrtcApm,
|
||||
agc: rust.BridgeEffectOwner.webrtcApm,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('VoiceSettingsResult', () {
|
||||
test('stores PTT mode result', () {
|
||||
final config = _defaultConfig();
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.ptt,
|
||||
releaseTailMs: 200,
|
||||
bindKeyRequested: true,
|
||||
audioConfig: config,
|
||||
);
|
||||
|
||||
expect(result.mode, rust.BridgeTransmitMode.ptt);
|
||||
expect(result.releaseTailMs, 200);
|
||||
expect(result.bindKeyRequested, isTrue);
|
||||
expect(result.audioConfig, config);
|
||||
});
|
||||
|
||||
test('stores continuous mode result without key binding', () {
|
||||
final config = _defaultConfig();
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.continuous,
|
||||
releaseTailMs: 0,
|
||||
bindKeyRequested: false,
|
||||
audioConfig: config,
|
||||
);
|
||||
|
||||
expect(result.mode, rust.BridgeTransmitMode.continuous);
|
||||
expect(result.releaseTailMs, 0);
|
||||
expect(result.bindKeyRequested, isFalse);
|
||||
});
|
||||
|
||||
test('stores voice activity mode result', () {
|
||||
final config = _defaultConfig();
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.voiceActivity,
|
||||
releaseTailMs: 100,
|
||||
bindKeyRequested: false,
|
||||
audioConfig: config,
|
||||
);
|
||||
|
||||
expect(result.mode, rust.BridgeTransmitMode.voiceActivity);
|
||||
expect(result.releaseTailMs, 100);
|
||||
});
|
||||
|
||||
test('preserves audio config fields', () {
|
||||
final config = rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.bluetoothHfp,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.webrtcVad,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.off,
|
||||
agc: rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: false,
|
||||
limiterEnabled: false,
|
||||
vadHangoverMs: 300,
|
||||
vadPreRollMs: 100,
|
||||
vadMinTxMs: 150,
|
||||
debugWavDumpEnabled: true,
|
||||
);
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.ptt,
|
||||
releaseTailMs: 250,
|
||||
bindKeyRequested: false,
|
||||
audioConfig: config,
|
||||
);
|
||||
|
||||
expect(result.audioConfig.route, rust.BridgeAudioRoute.bluetoothHfp);
|
||||
expect(result.audioConfig.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||
expect(result.audioConfig.ns, rust.BridgeEffectOwner.off);
|
||||
expect(result.audioConfig.hpfEnabled, isFalse);
|
||||
expect(result.audioConfig.debugWavDumpEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('release tail is always a round number', () {
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.ptt,
|
||||
releaseTailMs: 123,
|
||||
bindKeyRequested: false,
|
||||
audioConfig: _defaultConfig(),
|
||||
);
|
||||
|
||||
expect(result.releaseTailMs, 123);
|
||||
expect(result.releaseTailMs, equals(result.releaseTailMs.round()));
|
||||
});
|
||||
|
||||
test('bindKeyRequested defaults to false for save action', () {
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.ptt,
|
||||
releaseTailMs: 150,
|
||||
bindKeyRequested: false,
|
||||
audioConfig: _defaultConfig(),
|
||||
);
|
||||
|
||||
expect(result.bindKeyRequested, isFalse);
|
||||
});
|
||||
|
||||
test('bindKeyRequested is true for bind-key action', () {
|
||||
final result = VoiceSettingsResult(
|
||||
mode: rust.BridgeTransmitMode.ptt,
|
||||
releaseTailMs: 150,
|
||||
bindKeyRequested: true,
|
||||
audioConfig: _defaultConfig(),
|
||||
);
|
||||
|
||||
expect(result.bindKeyRequested, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('VoiceSettingsDialog state management', () {
|
||||
test('release tail is clamped between 0 and 500', () {
|
||||
expect(999.clamp(0, 500), 500);
|
||||
expect((-10).clamp(0, 500), 0);
|
||||
expect(250.clamp(0, 500), 250);
|
||||
});
|
||||
|
||||
test('transmit mode enum covers all three modes', () {
|
||||
expect(rust.BridgeTransmitMode.values, hasLength(3));
|
||||
expect(
|
||||
rust.BridgeTransmitMode.values,
|
||||
containsAll([
|
||||
rust.BridgeTransmitMode.ptt,
|
||||
rust.BridgeTransmitMode.continuous,
|
||||
rust.BridgeTransmitMode.voiceActivity,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# chanora_core
|
||||
|
||||
Top-level Rust API and orchestration layer for the Chanora client. Composes subsystem crates behind a stable, typed API consumed by `chanora_bridge`. Owns no protocol, audio, or storage logic directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
Per SAD §7.2, `chanora_core` is the integration point:
|
||||
|
||||
- **`ChanoraSession`** — the primary public type. Owns at most one active server connection (DEC-006). Provides connect, disconnect, snapshot, audio lifecycle, PTT, bookmarks, and diagnostics methods.
|
||||
- **Supervisor** — a per-connection tokio task that monitors connection health via a loss notifier and a watchdog probe, and auto-reconnects with exponential backoff (1 s → 60 s capped). Re-attaches the audio engine if it was running prior to the loss.
|
||||
- **`SessionEvent`** — broadcast enum emitted on connect/lost/reconnecting/disconnected/audio-started/audio-stopped/voice-state/chat/route changes. Subscribers consume via `subscribe_events()`.
|
||||
- **File transfer** — avatar/icon download routed through a cacache-backed blob cache with LRU eviction.
|
||||
- **Channel join state machine** — reducer-based state tracking for voice channel joins, with optimistic commands, snapshot reconciliation, and error projection.
|
||||
- **PTT controller** — platform input backend management, binding persistence, and release-tail timer wiring (SDD-088/094/096).
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Core types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `ChanoraSession` | Top-level session handle; cloneable, thread-safe |
|
||||
| `CoreError` | Unified error enum covering all subsystem errors |
|
||||
| `SessionEvent` | Broadcast lifecycle event enum |
|
||||
| `ConnectConfig` | Typed connection parameters |
|
||||
| `NetworkState` | OS connectivity state enum |
|
||||
|
||||
### Key methods on `ChanoraSession`
|
||||
|
||||
- `new()` — construct an empty session (no I/O)
|
||||
- `init_storage(dir)` — wire identity + bookmark stores
|
||||
- `init_cache(dir)` — wire the blob cache for avatars/icons
|
||||
- `connect(cfg)` → `ServerSnapshot` — dial a server (single-connection invariant)
|
||||
- `disconnect()` — clean teardown including supervisor
|
||||
- `is_connected()` — check connection state
|
||||
- `snapshot()` → `ServerSnapshot` — refresh server state
|
||||
- `client_profile(client_id)` — rich profile for one client
|
||||
- `voice_join(channel_id, password)` / `voice_leave()` — audio lifecycle
|
||||
- `start_audio(cfg)` — initialize audio subsystem
|
||||
- `set_input_device(id)` / `set_output_device(id)` — device selection
|
||||
- `set_output_gain(gain)` / `set_client_volume(client_id, volume)` — volume control
|
||||
- `set_transmit_mode(mode)` / `get_transmit_mode()` — transmit mode
|
||||
- `set_hard_mute(muted)` — hard-mute clamp
|
||||
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail config
|
||||
- `set_ptt(active)` / `set_ptt_binding(binding)` / `ptt_descriptor()` — PTT control
|
||||
- `send_text_message(message, target)` — chat
|
||||
- `move_to_channel(id, password)` / `set_self_muted(input, output)` — channel + mute
|
||||
- `subscribe_events()` — broadcast receiver for `SessionEvent`
|
||||
- `drain_protocol_events()` / `protocol_events_snapshot()` — protocol event access
|
||||
- `export_diagnostics()` — redacted diagnostic bundle (includes network stats)
|
||||
- `audio_stats()` — audio subsystem telemetry
|
||||
- `network_diagnostics_summary()` — network statistics
|
||||
- `prefetch_server(host)` — warm server-address resolution
|
||||
- `set_audio_processing_config(cfg)` / `get_audio_processing_config()` — audio DSP config
|
||||
- `set_audio_debug_wav_dump(enabled)` — WAV dump toggle
|
||||
- `set_vad_model_path(path)` — Silero model path
|
||||
- `transmit_selector()` / `release_tail_timer()` — subsystem accessors
|
||||
|
||||
### Re-exports
|
||||
|
||||
Re-exports selected types from `chanora_protocol`, `chanora_audio`, `chanora_storage`, and `chanora_diagnostics` so the bridge only depends on `chanora_core`.
|
||||
|
||||
## Platform notes
|
||||
|
||||
- iOS/macOS-specific methods (`ios_handle_route_change`, `ios_handle_interruption_began`, etc.) are gated behind `cfg(target_os = "ios" | "macos")` inside method bodies.
|
||||
- Android-specific reconnect paths are similarly gated.
|
||||
- The crate itself compiles on all targets; platform-specific code is runtime- or cfg-gated.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Single active connection at runtime (DEC-006)
|
||||
- `tsclientlib` types never cross out of `chanora_protocol` (SAD-067)
|
||||
- Secret material never lands in non-secret storage (DEC-013.2)
|
||||
- Audio engine construction failure preserves the previous engine state
|
||||
@@ -22,6 +22,17 @@ impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionEvent {
|
||||
/// Construct a `PttCapability` event from an audio backend descriptor.
|
||||
pub fn ptt_capability_from_descriptor(desc: &PttBackendDescriptor) -> Self {
|
||||
Self::PttCapability {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted PTT binding state exposed to callers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedPttBinding {
|
||||
@@ -235,7 +246,7 @@ pub enum SessionEvent {
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of channel-join projection sync state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VoiceJoinSyncState {
|
||||
/// Reducer is ready to accept channel actions.
|
||||
Ready,
|
||||
@@ -246,7 +257,7 @@ pub enum VoiceJoinSyncState {
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of stable channel-join error codes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VoiceJoinErrorCode {
|
||||
/// Duplicate same-target join intent was coalesced.
|
||||
DuplicateSameTargetCoalesced,
|
||||
@@ -285,3 +296,506 @@ pub enum NetworkState {
|
||||
/// OS reports no networks available.
|
||||
Offline,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_state_equality() {
|
||||
assert_eq!(NetworkState::Unknown, NetworkState::Unknown);
|
||||
assert_eq!(NetworkState::Online, NetworkState::Online);
|
||||
assert_eq!(NetworkState::Offline, NetworkState::Offline);
|
||||
assert_ne!(NetworkState::Unknown, NetworkState::Online);
|
||||
assert_ne!(NetworkState::Online, NetworkState::Offline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_ptt_binding_empty() {
|
||||
let binding = PersistedPttBinding::empty();
|
||||
assert_eq!(binding.input_class, "");
|
||||
assert_eq!(binding.key_label, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_ptt_binding_equality() {
|
||||
let a = PersistedPttBinding {
|
||||
input_class: "keyboard".to_string(),
|
||||
key_label: "Space".to_string(),
|
||||
};
|
||||
let b = PersistedPttBinding {
|
||||
input_class: "keyboard".to_string(),
|
||||
key_label: "Space".to_string(),
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_descriptor_snapshot_fields() {
|
||||
let snap = PttDescriptorSnapshot {
|
||||
level: "L0Focused".to_string(),
|
||||
backend_id: "focused".to_string(),
|
||||
bound_input_class: "keyboard".to_string(),
|
||||
};
|
||||
assert_eq!(snap.level, "L0Focused");
|
||||
assert_eq!(snap.backend_id, "focused");
|
||||
assert_eq!(snap.bound_input_class, "keyboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_connected() {
|
||||
let evt = SessionEvent::Connected {
|
||||
server_name: "Test Server".to_string(),
|
||||
};
|
||||
if let SessionEvent::Connected { server_name } = evt {
|
||||
assert_eq!(server_name, "Test Server");
|
||||
} else {
|
||||
panic!("expected Connected variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_lost() {
|
||||
let evt = SessionEvent::Lost {
|
||||
reason: "timeout".to_string(),
|
||||
};
|
||||
if let SessionEvent::Lost { reason } = evt {
|
||||
assert_eq!(reason, "timeout");
|
||||
} else {
|
||||
panic!("expected Lost variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_reconnecting() {
|
||||
let evt = SessionEvent::Reconnecting {
|
||||
attempt: 3,
|
||||
delay_secs: 30,
|
||||
};
|
||||
if let SessionEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(attempt, 3);
|
||||
assert_eq!(delay_secs, 30);
|
||||
} else {
|
||||
panic!("expected Reconnecting variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_disconnected() {
|
||||
let evt = SessionEvent::Disconnected {
|
||||
reason: "user".to_string(),
|
||||
};
|
||||
if let SessionEvent::Disconnected { reason } = evt {
|
||||
assert_eq!(reason, "user");
|
||||
} else {
|
||||
panic!("expected Disconnected variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_audio_started_stopped() {
|
||||
let _ = SessionEvent::AudioStarted;
|
||||
let _ = SessionEvent::AudioStopped;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_ptt_capability() {
|
||||
let evt = SessionEvent::PttCapability {
|
||||
level: "L1GlobalShortcut".to_string(),
|
||||
backend_id: "global".to_string(),
|
||||
bound_input_class: "keyboard".to_string(),
|
||||
};
|
||||
if let SessionEvent::PttCapability {
|
||||
level,
|
||||
backend_id,
|
||||
bound_input_class,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(level, "L1GlobalShortcut");
|
||||
assert_eq!(backend_id, "global");
|
||||
assert_eq!(bound_input_class, "keyboard");
|
||||
} else {
|
||||
panic!("expected PttCapability variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_voice_state() {
|
||||
let evt = SessionEvent::VoiceState {
|
||||
in_channel: true,
|
||||
transmit_mode: 1,
|
||||
mute: false,
|
||||
release_tail_ms: 200,
|
||||
current_channel_id: Some(42),
|
||||
pending_target_channel_id: None,
|
||||
can_join: false,
|
||||
can_leave: true,
|
||||
join_sync_state: VoiceJoinSyncState::Ready,
|
||||
join_error_code: None,
|
||||
};
|
||||
if let SessionEvent::VoiceState {
|
||||
in_channel,
|
||||
transmit_mode,
|
||||
mute,
|
||||
release_tail_ms,
|
||||
current_channel_id,
|
||||
pending_target_channel_id,
|
||||
can_join,
|
||||
can_leave,
|
||||
join_sync_state,
|
||||
join_error_code,
|
||||
} = evt
|
||||
{
|
||||
assert!(in_channel);
|
||||
assert_eq!(transmit_mode, 1);
|
||||
assert!(!mute);
|
||||
assert_eq!(release_tail_ms, 200);
|
||||
assert_eq!(current_channel_id, Some(42));
|
||||
assert_eq!(pending_target_channel_id, None);
|
||||
assert!(!can_join);
|
||||
assert!(can_leave);
|
||||
assert_eq!(join_sync_state, VoiceJoinSyncState::Ready);
|
||||
assert!(join_error_code.is_none());
|
||||
} else {
|
||||
panic!("expected VoiceState variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_interruption_state() {
|
||||
let evt = SessionEvent::InterruptionState {
|
||||
began: true,
|
||||
should_resume: false,
|
||||
};
|
||||
if let SessionEvent::InterruptionState {
|
||||
began,
|
||||
should_resume,
|
||||
} = evt
|
||||
{
|
||||
assert!(began);
|
||||
assert!(!should_resume);
|
||||
} else {
|
||||
panic!("expected InterruptionState variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_chat_message() {
|
||||
let evt = SessionEvent::ChatMessage {
|
||||
sender_id: 5,
|
||||
sender_name: "Alice".to_string(),
|
||||
message: "Hello".to_string(),
|
||||
target: chanora_protocol::MessageTarget::Channel,
|
||||
poke_strength: None,
|
||||
};
|
||||
if let SessionEvent::ChatMessage {
|
||||
sender_id,
|
||||
sender_name,
|
||||
message,
|
||||
target,
|
||||
poke_strength,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(sender_id, 5);
|
||||
assert_eq!(sender_name, "Alice");
|
||||
assert_eq!(message, "Hello");
|
||||
assert_eq!(target, chanora_protocol::MessageTarget::Channel);
|
||||
assert!(poke_strength.is_none());
|
||||
} else {
|
||||
panic!("expected ChatMessage variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_chat_message_with_poke() {
|
||||
let evt = SessionEvent::ChatMessage {
|
||||
sender_id: 3,
|
||||
sender_name: "Bob".to_string(),
|
||||
message: "".to_string(),
|
||||
target: chanora_protocol::MessageTarget::Poke(7),
|
||||
poke_strength: Some(chanora_protocol::PokeStrength::Suppressed),
|
||||
};
|
||||
if let SessionEvent::ChatMessage {
|
||||
target,
|
||||
poke_strength,
|
||||
..
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(target, chanora_protocol::MessageTarget::Poke(7));
|
||||
assert_eq!(poke_strength, Some(chanora_protocol::PokeStrength::Suppressed));
|
||||
} else {
|
||||
panic!("expected ChatMessage variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_server_activity() {
|
||||
let evt = SessionEvent::ServerActivity {
|
||||
message: "User joined channel".to_string(),
|
||||
};
|
||||
if let SessionEvent::ServerActivity { message } = &evt {
|
||||
assert_eq!(message, "User joined channel");
|
||||
} else {
|
||||
panic!("expected ServerActivity variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_audio_route_changed() {
|
||||
let evt = SessionEvent::AudioRouteChanged {
|
||||
route: chanora_audio::AudioRoute::Speaker,
|
||||
};
|
||||
if let SessionEvent::AudioRouteChanged { route } = &evt {
|
||||
assert_eq!(*route, chanora_audio::AudioRoute::Speaker);
|
||||
} else {
|
||||
panic!("expected AudioRouteChanged variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_client_moved() {
|
||||
let evt = SessionEvent::ClientMoved {
|
||||
client_id: 1,
|
||||
new_channel_id: 2,
|
||||
};
|
||||
if let SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(client_id, 1);
|
||||
assert_eq!(new_channel_id, 2);
|
||||
} else {
|
||||
panic!("expected ClientMoved variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_client_joined() {
|
||||
let evt = SessionEvent::ClientJoined {
|
||||
client_id: 10,
|
||||
channel_id: 3,
|
||||
name: "NewUser".to_string(),
|
||||
input_muted: false,
|
||||
output_muted: true,
|
||||
is_server_query: false,
|
||||
talk_power: 0,
|
||||
talk_power_granted: false,
|
||||
};
|
||||
if let SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(client_id, 10);
|
||||
assert_eq!(channel_id, 3);
|
||||
assert_eq!(name, "NewUser");
|
||||
assert!(!input_muted);
|
||||
assert!(output_muted);
|
||||
assert!(!is_server_query);
|
||||
assert_eq!(talk_power, 0);
|
||||
assert!(!talk_power_granted);
|
||||
} else {
|
||||
panic!("expected ClientJoined variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_client_left() {
|
||||
let evt = SessionEvent::ClientLeft {
|
||||
client_id: 10,
|
||||
name: "Departing".to_string(),
|
||||
};
|
||||
if let SessionEvent::ClientLeft { client_id, name } = evt {
|
||||
assert_eq!(client_id, 10);
|
||||
assert_eq!(name, "Departing");
|
||||
} else {
|
||||
panic!("expected ClientLeft variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_client_updated() {
|
||||
let evt = SessionEvent::ClientUpdated {
|
||||
client_id: 5,
|
||||
input_muted: true,
|
||||
output_muted: false,
|
||||
is_server_query: true,
|
||||
talk_power: 75,
|
||||
talk_power_granted: true,
|
||||
};
|
||||
if let SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(client_id, 5);
|
||||
assert!(input_muted);
|
||||
assert!(!output_muted);
|
||||
assert!(is_server_query);
|
||||
assert_eq!(talk_power, 75);
|
||||
assert!(talk_power_granted);
|
||||
} else {
|
||||
panic!("expected ClientUpdated variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_channel_added() {
|
||||
let evt = SessionEvent::ChannelAdded {
|
||||
id: 7,
|
||||
parent: 1,
|
||||
name: "Sub".to_string(),
|
||||
order: 3,
|
||||
has_password: true,
|
||||
needed_talk_power: Some(50),
|
||||
};
|
||||
if let SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(id, 7);
|
||||
assert_eq!(parent, 1);
|
||||
assert_eq!(name, "Sub");
|
||||
assert_eq!(order, 3);
|
||||
assert!(has_password);
|
||||
assert_eq!(needed_talk_power, Some(50));
|
||||
} else {
|
||||
panic!("expected ChannelAdded variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_channel_removed() {
|
||||
let evt = SessionEvent::ChannelRemoved { id: 7 };
|
||||
if let SessionEvent::ChannelRemoved { id } = evt {
|
||||
assert_eq!(id, 7);
|
||||
} else {
|
||||
panic!("expected ChannelRemoved variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_channel_updated() {
|
||||
let evt = SessionEvent::ChannelUpdated {
|
||||
id: 7,
|
||||
name: "Renamed".to_string(),
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
};
|
||||
if let SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} = evt
|
||||
{
|
||||
assert_eq!(id, 7);
|
||||
assert_eq!(name, "Renamed");
|
||||
assert!(!has_password);
|
||||
assert!(needed_talk_power.is_none());
|
||||
} else {
|
||||
panic!("expected ChannelUpdated variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_join_sync_state_variants() {
|
||||
let ready = VoiceJoinSyncState::Ready;
|
||||
let init = VoiceJoinSyncState::SynchronizingInitialSnapshot;
|
||||
let reconnect = VoiceJoinSyncState::SynchronizingReconnect;
|
||||
assert_ne!(
|
||||
std::mem::discriminant(&ready),
|
||||
std::mem::discriminant(&init)
|
||||
);
|
||||
assert_ne!(
|
||||
std::mem::discriminant(&init),
|
||||
std::mem::discriminant(&reconnect)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_join_error_code_all_variants() {
|
||||
let codes = [
|
||||
VoiceJoinErrorCode::DuplicateSameTargetCoalesced,
|
||||
VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget,
|
||||
VoiceJoinErrorCode::JoinDenied,
|
||||
VoiceJoinErrorCode::JoinProtocolFailure,
|
||||
VoiceJoinErrorCode::JoinNetworkFailure,
|
||||
VoiceJoinErrorCode::JoinTimeout,
|
||||
VoiceJoinErrorCode::JoinSupersededByLeave,
|
||||
VoiceJoinErrorCode::JoinStaleOutcomeIgnored,
|
||||
VoiceJoinErrorCode::JoinReconciledDifferentChannel,
|
||||
VoiceJoinErrorCode::JoinCommandRejectedBeforeSend,
|
||||
VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing,
|
||||
];
|
||||
for i in 0..codes.len() {
|
||||
for j in 0..codes.len() {
|
||||
if i == j {
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&codes[i]),
|
||||
std::mem::discriminant(&codes[j])
|
||||
);
|
||||
} else {
|
||||
assert_ne!(
|
||||
std::mem::discriminant(&codes[i]),
|
||||
std::mem::discriminant(&codes[j])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_clone_preserves_fields() {
|
||||
let evt = SessionEvent::Connected {
|
||||
server_name: "Cloneable".to_string(),
|
||||
};
|
||||
let cloned = evt.clone();
|
||||
if let SessionEvent::Connected { server_name } = cloned {
|
||||
assert_eq!(server_name, "Cloneable");
|
||||
} else {
|
||||
panic!("expected Connected variant after clone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_event_voice_state_with_join_error() {
|
||||
let evt = SessionEvent::VoiceState {
|
||||
in_channel: false,
|
||||
transmit_mode: 0,
|
||||
mute: false,
|
||||
release_tail_ms: 200,
|
||||
current_channel_id: None,
|
||||
pending_target_channel_id: None,
|
||||
can_join: true,
|
||||
can_leave: false,
|
||||
join_sync_state: VoiceJoinSyncState::Ready,
|
||||
join_error_code: Some(VoiceJoinErrorCode::JoinDenied),
|
||||
};
|
||||
if let SessionEvent::VoiceState { join_error_code, .. } = evt {
|
||||
assert_eq!(join_error_code, Some(VoiceJoinErrorCode::JoinDenied));
|
||||
} else {
|
||||
panic!("expected VoiceState variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ pub use chanora_diagnostics::{
|
||||
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
|
||||
};
|
||||
pub use chanora_protocol::{
|
||||
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
||||
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
validate_nickname, ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig,
|
||||
DisconnectReason, MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||
pub use events::{
|
||||
@@ -924,21 +924,13 @@ impl ChanoraSession {
|
||||
// dialog) re-publish through the controller's
|
||||
// descriptor-watch.
|
||||
let initial_desc = controller.descriptor().await;
|
||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
||||
level: initial_desc.level.as_str().to_string(),
|
||||
backend_id: initial_desc.backend_id.to_string(),
|
||||
bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
let _ = self.events_tx.send(SessionEvent::ptt_capability_from_descriptor(&initial_desc));
|
||||
let mut watch_rx = controller.descriptor_watch();
|
||||
let events_tx = self.events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while watch_rx.changed().await.is_ok() {
|
||||
let d = watch_rx.borrow_and_update().clone();
|
||||
let _ = events_tx.send(SessionEvent::PttCapability {
|
||||
level: d.level.as_str().to_string(),
|
||||
backend_id: d.backend_id.to_string(),
|
||||
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
let _ = events_tx.send(SessionEvent::ptt_capability_from_descriptor(&d));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
@@ -1000,11 +992,7 @@ impl ChanoraSession {
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if let Some(controller) = state.ptt_controller.as_ref() {
|
||||
let desc = controller.set_binding(binding).await?;
|
||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
let _ = self.events_tx.send(SessionEvent::ptt_capability_from_descriptor(&desc));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1125,6 +1113,21 @@ impl ChanoraSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current master output gain. Returns `1.0` (unity) if
|
||||
/// audio is not started.
|
||||
pub async fn output_gain(&self) -> f32 {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = match guard.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return 1.0,
|
||||
};
|
||||
let audio = match state.audio.as_ref() {
|
||||
Some(a) => a,
|
||||
None => return 1.0,
|
||||
};
|
||||
audio.output_gain()
|
||||
}
|
||||
|
||||
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
|
||||
/// mutes. No-op if audio is not started or client has no active
|
||||
/// voice queue.
|
||||
@@ -2269,14 +2272,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
// capability (SRS-196 / SDD-091).
|
||||
let d = controller.descriptor().await;
|
||||
let _ =
|
||||
events_tx.send(SessionEvent::PttCapability {
|
||||
level: d.level.as_str().to_string(),
|
||||
backend_id: d.backend_id.to_string(),
|
||||
bound_input_class: d
|
||||
.bound_input_class
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
});
|
||||
events_tx.send(SessionEvent::ptt_capability_from_descriptor(&d));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# chanora_audio
|
||||
|
||||
Real-time audio subsystem: capture, Opus encoding/decoding, voice rendering, PTT gating, and audio processing. Promoted from `poc/audio-capture-playback-spike`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Engine
|
||||
|
||||
- **`AudioEngine`** — the primary type. Starts a platform audio backend (capture + playback), wires an `AudioTransmitGate` for PTT gating, and feeds encoded Opus frames to the protocol layer via `voice_out`. Inbound voice packets are decoded and mixed by `tsclientlib::audio::AudioHandler` and pulled by the platform output callback at 48 kHz stereo.
|
||||
|
||||
### Platform backends (cfg-gated)
|
||||
|
||||
| Target | Backend | Notes |
|
||||
|---|---|---|
|
||||
| Android | Oboe (via `android_voice_unit`) | Requires `ndk_context` before start |
|
||||
| iOS/macOS | Apple VoiceProcessingIO (`ios_voice_unit`) | Platform AEC/AGC/NS, route-change handling |
|
||||
| Linux | SDL (`sdl_output`) | PulseAudio/ALSA via SDL |
|
||||
| Other desktop | cpal | Fallback |
|
||||
|
||||
### Key modules
|
||||
|
||||
- **`audio_processing`** — P1 audio processing config, stats, route policy, effect ownership (Platform/Sonora/WebRTC APM)
|
||||
- **`opus_voice`** — 20 ms / 48 kHz mono Opus encode/decode via `audiopus`
|
||||
- **`transmit_mode`** — `TransmitMode` enum: Ptt, Continuous, VoiceActivity
|
||||
- **`transmit_selector`** — `TransmitModeSelector` combining mode, hard-mute, PTT gate, permission gate, and in-channel state
|
||||
- **`ptt`** — `AudioTransmitGate` (atomic bool), `PttCapabilityLevel`, `PttBackendDescriptor`
|
||||
- **`ptt_backends`** — platform PTT backends: `DesktopPttBackend` (Linux portal), `FocusedPttBackend` (in-app fallback)
|
||||
- **`release_tail`** — `ReleaseTailTimer` for configurable PTT release delay (default 200 ms, max 500 ms)
|
||||
- **`vad`** — Voice-activity detection: Silero ONNX (desktop), WebRTC fallback, energy debug
|
||||
- **`voice_render`** — mixes per-client decoded f32 PCM into the output buffer
|
||||
- **`debug_wav`** — optional WAV file dump for diagnostics (DIAG_002/003)
|
||||
- **`mobile_voice_backend`** — shared mobile voice-unit lifecycle abstraction
|
||||
- **`frame`** — frame-aligned buffer utilities
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `AudioEngine` | Start/stop audio, set gain/mute/volume, read stats |
|
||||
| `AudioEngineConfig` | Capture/playback device selection, PTT initial state, processing config |
|
||||
| `AudioDeviceInfo` / `AudioDeviceList` | Device enumeration |
|
||||
| `AudioTransmitGate` | Atomic PTT gate |
|
||||
| `TransmitMode` / `TransmitModeSelector` | Mode selection with hard-mute clamp |
|
||||
| `ReleaseTailTimer` | Configurable release delay (SDD-096) |
|
||||
| `PttBinding` / `PttInputClass` | PTT key binding types |
|
||||
| `PttBackendDescriptor` / `PttCapabilityLevel` | Capability query |
|
||||
| `AudioProcessingConfig` / `AudioProcessingStats` | P1 processing control and telemetry |
|
||||
| `AudioRoute` | Speaker/Earpiece/Wired/Bluetooth enum |
|
||||
| `AudioEffects` | Effect toggles (AEC/AGC/NS/HPF), all enabled by default (DEC-007..010) |
|
||||
| `AudioError` | Typed error catalogue |
|
||||
|
||||
### Key functions
|
||||
|
||||
- `AudioEngine::start_with_gate(cfg, voice_out, voice_in, gate)` — construct and start
|
||||
- `AudioEngine::stop()` — tear down
|
||||
- `list_audio_devices()` — enumerate available input/output devices
|
||||
- `select_ptt_backend()` — choose the best PTT backend for the current platform
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Android requires `initChanoraContext` (NDK context) before engine start.
|
||||
- iOS/macOS uses VoiceProcessingIO for platform AEC/AGC/NS in the default route.
|
||||
- Desktop can use Silero ONNX VAD when the model file is available.
|
||||
- `bench_seam` is exposed (`#[doc(hidden)]`) for criterion benchmarks on non-mobile targets.
|
||||
@@ -855,7 +855,7 @@ impl AndroidVoiceUnit {
|
||||
// by the capture callback's WebRtcApmProcessor.
|
||||
{
|
||||
use crate::audio_processing::EffectOwner;
|
||||
let mut apm_cfg = apm_config_clone.lock().unwrap();
|
||||
let mut apm_cfg = apm_config_clone.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let hw_aec = hw_effects.aec.is_some();
|
||||
let hw_ns = hw_effects.ns.is_some();
|
||||
let hw_agc = hw_effects.agc.is_some();
|
||||
|
||||
@@ -24,8 +24,8 @@ pub enum AudioCommand {
|
||||
/// Set a client's output volume.
|
||||
SetVolume(SessionAudioId, f32),
|
||||
/// Remove a client's decode queue.
|
||||
// TODO: Wire to client disconnect path; handled in callback but no
|
||||
// producer currently pushes this command.
|
||||
// TRACKED(TODO-005): Wire to client disconnect path; handled in callback
|
||||
// but no producer currently pushes this command.
|
||||
#[allow(dead_code)]
|
||||
RemoveClient(SessionAudioId),
|
||||
}
|
||||
|
||||
@@ -210,17 +210,6 @@ impl AudioProcessingConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Demote a failed VAD backend to the WebRTC fallback.
|
||||
///
|
||||
/// Returns `true` when the config changed.
|
||||
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
|
||||
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
|
||||
self.vad_backend = VadBackend::WebrtcVad;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -251,18 +240,6 @@ mod tests {
|
||||
|
||||
assert!(config.validate_for_ios().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
||||
let mut config = AudioProcessingConfig {
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
||||
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
|
||||
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cpal::traits::StreamTrait;
|
||||
use cpal::{SampleFormat, SizedSample};
|
||||
use tracing::{debug, error, warn, info};
|
||||
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
use super::SAMPLE_RATE;
|
||||
use super::FRAME_SAMPLES;
|
||||
|
||||
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
|
||||
|
||||
pub(super) fn try_open_capture(
|
||||
in_dev: &cpal::Device,
|
||||
voice_out_tx: tokio::sync::mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<cpal::Stream, AudioError> {
|
||||
let in_cfg = in_dev
|
||||
.default_input_config()
|
||||
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
|
||||
let in_sample_rate = in_cfg.sample_rate();
|
||||
let in_channels = in_cfg.channels() as usize;
|
||||
let in_format = in_cfg.sample_format();
|
||||
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
|
||||
}
|
||||
|
||||
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
|
||||
|
||||
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||
opus_enc,
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
mic_gain,
|
||||
crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"cpal-capture",
|
||||
)?,
|
||||
transmit_active,
|
||||
voice_activity_selector,
|
||||
audio_processing_config,
|
||||
silero_vad_worker,
|
||||
audio_processing_stats,
|
||||
)));
|
||||
|
||||
let stream = match in_format {
|
||||
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
|
||||
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||
other => {
|
||||
return Err(AudioError::StreamConfig(format!(
|
||||
"unsupported input format: {other:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub(super) struct CaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pub(super) pcm_accum: Vec<f32>,
|
||||
pub(super) pending_10ms: [f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pub(super) pending_10ms_len: usize,
|
||||
pub(super) capture_frame_seq: u64,
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
mic_gain: f32,
|
||||
resample_pos: f64,
|
||||
resample_last: f32,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
silero_model_epoch: u64,
|
||||
current_vad_backend: crate::VadBackend,
|
||||
fallback_warned_backend: Option<crate::VadBackend>,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
mono_scratch: Vec<f32>,
|
||||
frame_scratch: Vec<f32>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
last_level_emit: std::time::Instant,
|
||||
}
|
||||
|
||||
impl CaptureState {
|
||||
pub(super) fn new(
|
||||
encoder: OpusEncoder,
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
mic_gain: f32,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
encoder,
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
mic_gain,
|
||||
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||
resample_pos: 0.0,
|
||||
resample_last: 0.0,
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
silero_vad_worker,
|
||||
silero_model_epoch: crate::vad::silero_model_epoch(),
|
||||
current_vad_backend: crate::VadBackend::Disabled,
|
||||
fallback_warned_backend: None,
|
||||
capture_frame_seq: 0,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||
audio_processing_config,
|
||||
pending_10ms: [0.0; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
mono_scratch: Vec::with_capacity(4096),
|
||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||
audio_processing_stats,
|
||||
last_level_emit: std::time::Instant::now()
|
||||
.checked_sub(LEVEL_METER_INTERVAL)
|
||||
.unwrap_or_else(std::time::Instant::now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||
let in_channels = self.in_channels;
|
||||
let mic_gain = self.mic_gain;
|
||||
self.mono_scratch.clear();
|
||||
let frame_count = buf.len() / in_channels.max(1);
|
||||
self.mono_scratch.reserve(frame_count);
|
||||
for frame in buf.chunks(in_channels) {
|
||||
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||
self.mono_scratch.push(sum / frame.len() as f32);
|
||||
}
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
|
||||
self.last_level_emit = now;
|
||||
self.audio_processing_stats
|
||||
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
|
||||
}
|
||||
|
||||
if mic_gain != 1.0 {
|
||||
for s in &mut self.mono_scratch {
|
||||
*s *= mic_gain;
|
||||
}
|
||||
}
|
||||
|
||||
let vad_start_offset = self.pcm_accum.len();
|
||||
if self.in_sample_rate == SAMPLE_RATE {
|
||||
let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum);
|
||||
dst.extend_from_slice(src);
|
||||
} else {
|
||||
let mono = std::mem::take(&mut self.mono_scratch);
|
||||
self.resample_into_accum(&mono);
|
||||
self.mono_scratch = mono;
|
||||
}
|
||||
|
||||
self.process_pending_vad_frames(vad_start_offset);
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
||||
let frame = &mut self.frame_scratch;
|
||||
frame.clear();
|
||||
frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES));
|
||||
for s in frame.iter_mut() {
|
||||
if *s > 1.0 {
|
||||
*s = 1.0;
|
||||
} else if *s < -1.0 {
|
||||
*s = -1.0;
|
||||
}
|
||||
}
|
||||
match self
|
||||
.encoder
|
||||
.encode_float(&frame[..], &mut self.opus_out[..])
|
||||
{
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"voice_out queue full; dropping frame"
|
||||
);
|
||||
},
|
||||
|| {
|
||||
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_pending_vad_frames(&mut self, start_offset: usize) {
|
||||
let mut offset = start_offset.min(self.pcm_accum.len());
|
||||
while offset < self.pcm_accum.len() {
|
||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||
let take = remaining.min(self.pcm_accum.len() - offset);
|
||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||
.copy_from_slice(&self.pcm_accum[offset..offset + take]);
|
||||
self.pending_10ms_len += take;
|
||||
offset += take;
|
||||
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
||||
self.fallback_warned_backend = Some(failed_backend);
|
||||
}
|
||||
|
||||
fn sync_vad_backend(&mut self, voice_activity_mode: bool, vad_backend: crate::VadBackend) {
|
||||
if !voice_activity_mode {
|
||||
self.current_vad_backend = crate::VadBackend::Disabled;
|
||||
self.fallback_warned_backend = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let silero_epoch = crate::vad::silero_model_epoch();
|
||||
let silero_changed =
|
||||
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
|
||||
if vad_backend == self.current_vad_backend && !silero_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.silero_model_epoch = silero_epoch;
|
||||
self.fallback_warned_backend = None;
|
||||
self.vad_state.reset();
|
||||
|
||||
match vad_backend {
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
let worker_available = self
|
||||
.silero_vad_worker
|
||||
.try_lock()
|
||||
.map(|worker| worker.is_some())
|
||||
.unwrap_or(false);
|
||||
if worker_available {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
} else {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
}
|
||||
}
|
||||
crate::VadBackend::WebrtcVad => {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
crate::VadBackend::EnergyDebug => {
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
}
|
||||
crate::VadBackend::Disabled => {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_10ms_capture_frame(&mut self, frame: &[f32; crate::frame::FRAME_10MS_SAMPLES]) {
|
||||
let input_dbfs = crate::frame::dbfs(frame);
|
||||
let (vad_backend, vad_hangover) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||
.unwrap_or((
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
));
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
||||
.unwrap_or(false);
|
||||
|
||||
if voice_activity_mode {
|
||||
self.sync_vad_backend(true, vad_backend);
|
||||
self.vad_state.configure(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
} else {
|
||||
self.sync_vad_backend(false, vad_backend);
|
||||
}
|
||||
|
||||
let (vad_probability, gate_open, used_fallback_vad) = if voice_activity_mode {
|
||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||
let capture_seq = self.capture_frame_seq;
|
||||
let mut used_fallback_vad = false;
|
||||
let vad = match vad_backend {
|
||||
crate::VadBackend::Disabled => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
let worker_output = {
|
||||
let guard = self.silero_vad_worker.try_lock().ok();
|
||||
guard.and_then(|guard| {
|
||||
let worker = guard.as_ref()?;
|
||||
if worker.try_send(capture_seq, frame) && !worker.is_stale(capture_seq)
|
||||
{
|
||||
let p = worker.latest_probability();
|
||||
Some(crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(output) = worker_output {
|
||||
output
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
frame,
|
||||
)
|
||||
}
|
||||
}
|
||||
crate::VadBackend::WebrtcVad | crate::VadBackend::EnergyDebug => {
|
||||
used_fallback_vad = vad_backend == crate::VadBackend::EnergyDebug;
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, frame)
|
||||
}
|
||||
};
|
||||
(
|
||||
vad.probability,
|
||||
self.vad_state.update(vad.speech),
|
||||
used_fallback_vad,
|
||||
)
|
||||
} else {
|
||||
(0.0, false, false)
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(used_fallback_vad);
|
||||
|
||||
let vad_active = voice_activity_mode && gate_open;
|
||||
if let Some(selector) = &self.voice_activity_selector {
|
||||
selector.set_voice_activity_open(vad_active);
|
||||
}
|
||||
self.audio_processing_stats.update_capture(
|
||||
input_dbfs,
|
||||
input_dbfs,
|
||||
vad_probability,
|
||||
vad_active,
|
||||
self.transmit_active.load(Ordering::Relaxed),
|
||||
);
|
||||
self.audio_processing_stats
|
||||
.record_capture_frame(frame.iter().all(|sample| sample.abs() <= 0.000_001));
|
||||
}
|
||||
|
||||
fn resample_into_accum(&mut self, mono: &[f32]) {
|
||||
if mono.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
|
||||
let mut pos = self.resample_pos;
|
||||
while pos < mono.len() as f64 {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let a = if i <= 0 {
|
||||
self.resample_last
|
||||
} else {
|
||||
mono[(i - 1) as usize]
|
||||
};
|
||||
let b = if i < mono.len() as isize {
|
||||
mono[i as usize]
|
||||
} else {
|
||||
a
|
||||
};
|
||||
self.pcm_accum
|
||||
.push((a as f64 + frac * (b - a) as f64) as f32);
|
||||
pos += ratio;
|
||||
}
|
||||
self.resample_pos = pos - mono.len() as f64;
|
||||
self.resample_last = *mono.last().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
trait ToF32 {
|
||||
fn to_f32_sample(self) -> f32;
|
||||
}
|
||||
impl ToF32 for f32 {
|
||||
fn to_f32_sample(self) -> f32 {
|
||||
self
|
||||
}
|
||||
}
|
||||
impl ToF32 for i16 {
|
||||
fn to_f32_sample(self) -> f32 {
|
||||
f32::from(self) / f32::from(i16::MAX)
|
||||
}
|
||||
}
|
||||
impl ToF32 for u16 {
|
||||
fn to_f32_sample(self) -> f32 {
|
||||
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
state: Arc<Mutex<CaptureState>>,
|
||||
) -> Result<cpal::Stream, AudioError>
|
||||
where
|
||||
T: SizedSample + ToF32 + Send + 'static,
|
||||
{
|
||||
let stream = device
|
||||
.build_input_stream(
|
||||
*config,
|
||||
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
s.ingest(data);
|
||||
},
|
||||
move |e| {
|
||||
error!(target: "chanora_audio", error = %e, "input stream error");
|
||||
},
|
||||
None,
|
||||
)
|
||||
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub mod bench_seam {
|
||||
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct CaptureBenchHandle {
|
||||
pub(crate) state: CaptureState,
|
||||
_rx: mpsc::Receiver<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CaptureBenchHandle {
|
||||
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
|
||||
let encoder =
|
||||
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
|
||||
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
||||
let transmit_active = Arc::new(AtomicBool::new(true));
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let voice_out_tx =
|
||||
crate::opus_voice::start_out_packet_worker(tx, frames_sent, "cpal-bench")
|
||||
.expect("start_out_packet_worker");
|
||||
let state = CaptureState::new(
|
||||
encoder,
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
1.0,
|
||||
voice_out_tx,
|
||||
transmit_active.clone(),
|
||||
None,
|
||||
Arc::new(std::sync::Mutex::new(
|
||||
crate::AudioProcessingConfig::default(),
|
||||
)),
|
||||
Arc::new(std::sync::Mutex::new(None)),
|
||||
Arc::new(crate::SharedAudioProcessingStats::default()),
|
||||
);
|
||||
Self {
|
||||
state,
|
||||
_rx: rx,
|
||||
transmit_active,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn ingest_f32(&mut self, buf: &[f32]) {
|
||||
self.state.ingest(buf);
|
||||
}
|
||||
|
||||
pub fn set_transmit_active(&self, active: bool) {
|
||||
self.transmit_active
|
||||
.store(active, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cpal::traits::StreamTrait;
|
||||
use cpal::SampleFormat;
|
||||
use tracing::{error, warn};
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
use super::SessionAudioId;
|
||||
use super::SAMPLE_RATE;
|
||||
|
||||
pub(super) fn build_output_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
dev_sample_rate: u32,
|
||||
dev_channels: usize,
|
||||
) -> Result<cpal::Stream, AudioError>
|
||||
where
|
||||
T: cpal::SizedSample + FromF32 + Send + 'static,
|
||||
{
|
||||
let resample_ratio = SAMPLE_RATE as f64 / dev_sample_rate as f64;
|
||||
let same_rate = dev_sample_rate == SAMPLE_RATE;
|
||||
let resample_state: Arc<Mutex<PlaybackResampleState>> =
|
||||
Arc::new(Mutex::new(PlaybackResampleState {
|
||||
pos: 0.0,
|
||||
last_l: 0.0,
|
||||
last_r: 0.0,
|
||||
}));
|
||||
let mut scratch: Vec<f32> = Vec::with_capacity(8192);
|
||||
let mut last_slow_warn = std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_secs(2))
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
*config,
|
||||
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
let cb_start = std::time::Instant::now();
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let dev_frames = out.len() / dev_channels.max(1);
|
||||
let src_frames = if same_rate {
|
||||
dev_frames
|
||||
} else {
|
||||
((dev_frames as f64 * resample_ratio).ceil() as usize) + 2
|
||||
};
|
||||
let needed = src_frames * 2;
|
||||
if scratch.len() < needed {
|
||||
scratch.resize(needed, 0.0);
|
||||
}
|
||||
scratch[..needed].fill(0.0);
|
||||
{
|
||||
let mut h = handler.lock().unwrap_or_else(|e| e.into_inner());
|
||||
h.fill_buffer(&mut scratch[..needed]);
|
||||
}
|
||||
|
||||
if muted {
|
||||
for dst in out.iter_mut() {
|
||||
*dst = T::from_f32_sample(0.0);
|
||||
}
|
||||
} else {
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
if same_rate && dev_channels == 2 {
|
||||
for (dst, s) in out.iter_mut().zip(scratch[..needed].iter().copied()) {
|
||||
*dst = T::from_f32_sample(s * gain);
|
||||
}
|
||||
} else {
|
||||
let mut state = resample_state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut pos = state.pos;
|
||||
let mut last_l = state.last_l;
|
||||
let mut last_r = state.last_r;
|
||||
for frame_idx in 0..dev_frames {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let (a_l, a_r) = if i <= 0 {
|
||||
(last_l, last_r)
|
||||
} else {
|
||||
let idx = ((i - 1) as usize) * 2;
|
||||
(scratch[idx], scratch[idx + 1])
|
||||
};
|
||||
let i_usize = i.max(0) as usize;
|
||||
let (b_l, b_r) = if i_usize < src_frames {
|
||||
let idx = i_usize * 2;
|
||||
(scratch[idx], scratch[idx + 1])
|
||||
} else {
|
||||
(a_l, a_r)
|
||||
};
|
||||
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
|
||||
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
|
||||
let base = frame_idx * dev_channels;
|
||||
if dev_channels == 1 {
|
||||
out[base] = T::from_f32_sample((l + r) * 0.5);
|
||||
} else {
|
||||
out[base] = T::from_f32_sample(l);
|
||||
if dev_channels >= 2 {
|
||||
out[base + 1] = T::from_f32_sample(r);
|
||||
}
|
||||
for c in 2..dev_channels {
|
||||
out[base + c] = T::from_f32_sample(0.0);
|
||||
}
|
||||
}
|
||||
pos += resample_ratio;
|
||||
}
|
||||
let consumed = pos.floor() as usize;
|
||||
state.pos = pos - consumed as f64;
|
||||
if consumed > 0 && consumed <= src_frames {
|
||||
let idx = (consumed - 1) * 2;
|
||||
last_l = scratch[idx];
|
||||
last_r = scratch[idx + 1];
|
||||
state.last_l = last_l;
|
||||
state.last_r = last_r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = cb_start.elapsed();
|
||||
let period_us = (dev_frames as u64 * 1_000_000) / dev_sample_rate as u64;
|
||||
if elapsed.as_micros() as u64 > period_us / 2
|
||||
&& last_slow_warn.elapsed() > std::time::Duration::from_secs(1)
|
||||
{
|
||||
last_slow_warn = std::time::Instant::now();
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
callback_us = elapsed.as_micros() as u64,
|
||||
period_us,
|
||||
dev_frames,
|
||||
"output callback exceeded half the period budget — possible underrun cause"
|
||||
);
|
||||
}
|
||||
},
|
||||
move |e| {
|
||||
error!(target: "chanora_audio", error = %e, "output stream error");
|
||||
},
|
||||
None,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
requested_channels = config.channels,
|
||||
requested_sample_rate = config.sample_rate,
|
||||
"build_output_stream FAILED"
|
||||
);
|
||||
AudioError::Backend(format!("build_output_stream: {e}"))
|
||||
})?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
struct PlaybackResampleState {
|
||||
pos: f64,
|
||||
last_l: f32,
|
||||
last_r: f32,
|
||||
}
|
||||
|
||||
pub(super) trait FromF32 {
|
||||
fn from_f32_sample(v: f32) -> Self;
|
||||
}
|
||||
impl FromF32 for f32 {
|
||||
fn from_f32_sample(v: f32) -> Self {
|
||||
v
|
||||
}
|
||||
}
|
||||
impl FromF32 for i16 {
|
||||
fn from_f32_sample(v: f32) -> Self {
|
||||
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
|
||||
}
|
||||
}
|
||||
impl FromF32 for u16 {
|
||||
fn from_f32_sample(v: f32) -> Self {
|
||||
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
|
||||
(s + i32::from(i16::MAX) + 1) as u16
|
||||
}
|
||||
}
|
||||
@@ -16,56 +16,12 @@ pub const FRAME_10MS_SAMPLES: usize = 480;
|
||||
/// Samples in one 20 ms mono frame at 48 kHz.
|
||||
pub const FRAME_20MS_SAMPLES: usize = 960;
|
||||
|
||||
/// 10 ms, 48 kHz, mono f32 processing frame.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AudioFrame10ms {
|
||||
/// Samples normalized to `[-1.0, 1.0]`.
|
||||
pub samples: [f32; FRAME_10MS_SAMPLES],
|
||||
}
|
||||
|
||||
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AudioFrame20ms {
|
||||
/// Samples normalized to `[-1.0, 1.0]`.
|
||||
pub samples: [f32; FRAME_20MS_SAMPLES],
|
||||
}
|
||||
|
||||
impl AudioFrame20ms {
|
||||
/// Convert one 20 ms frame into two 10 ms processing frames.
|
||||
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
|
||||
let mut first = [0.0; FRAME_10MS_SAMPLES];
|
||||
let mut second = [0.0; FRAME_10MS_SAMPLES];
|
||||
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
|
||||
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
|
||||
(
|
||||
AudioFrame10ms { samples: first },
|
||||
AudioFrame10ms { samples: second },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioFrame10ms {
|
||||
/// Merge two 10 ms processing frames back into the 20 ms network
|
||||
/// cadence used by the existing Opus path.
|
||||
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
|
||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
||||
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
|
||||
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
|
||||
AudioFrame20ms { samples }
|
||||
}
|
||||
|
||||
/// Compute RMS dBFS for diagnostics and fallback VAD.
|
||||
pub fn dbfs(&self) -> f32 {
|
||||
dbfs(&self.samples)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert i16 PCM to normalized f32 PCM.
|
||||
/// Convert i16 PCM sample to normalized f32 PCM (-1.0 to 1.0).
|
||||
pub fn i16_to_f32(sample: i16) -> f32 {
|
||||
sample as f32 / i16::MAX as f32
|
||||
}
|
||||
|
||||
/// Convert normalized f32 PCM to saturated i16 PCM.
|
||||
/// Convert normalized f32 PCM to saturated i16 PCM (clamps to [-1.0, 1.0]).
|
||||
pub fn f32_to_i16(sample: f32) -> i16 {
|
||||
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||
}
|
||||
@@ -84,18 +40,4 @@ pub fn dbfs(samples: &[f32]) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_merge_preserves_samples() {
|
||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
||||
for (i, s) in samples.iter_mut().enumerate() {
|
||||
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
|
||||
}
|
||||
let original = AudioFrame20ms { samples };
|
||||
let (a, b) = original.split();
|
||||
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ impl IosVoiceUnit {
|
||||
let unit_arc2 = Arc::clone(&unit_arc);
|
||||
|
||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||
let mut guard = unit_arc2.lock().unwrap();
|
||||
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let unit = guard.as_mut().unwrap();
|
||||
let _ = tx.send(op(unit));
|
||||
});
|
||||
@@ -559,7 +559,7 @@ impl IosVoiceUnit {
|
||||
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
|
||||
};
|
||||
|
||||
self.unit = unit_arc.lock().unwrap().take();
|
||||
self.unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
result.map_err(AudioError::Backend)
|
||||
}
|
||||
|
||||
@@ -1072,7 +1072,7 @@ impl IosVoiceUnit {
|
||||
let unit_arc2 = unit_arc.clone();
|
||||
|
||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||
let mut guard = unit_arc2.lock().unwrap();
|
||||
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let u = guard.as_mut().unwrap();
|
||||
let result = u
|
||||
.initialize()
|
||||
@@ -1094,7 +1094,7 @@ impl IosVoiceUnit {
|
||||
}
|
||||
}
|
||||
|
||||
unit = unit_arc.lock().unwrap().take().unwrap();
|
||||
unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take().unwrap();
|
||||
}
|
||||
|
||||
info!(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! `cpal` (and SDL on Linux) own desktop capture/playback per the
|
||||
//! existing audio engine design.
|
||||
|
||||
// TODO(SDD-117): back-fill `IosVoiceUnit` to implement this trait
|
||||
// TRACKED(SDD-117): back-fill `IosVoiceUnit` to implement this trait
|
||||
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
|
||||
// across iOS and Android.
|
||||
|
||||
|
||||
@@ -543,7 +543,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
|
||||
}
|
||||
let runloop = unsafe { CFRunLoopGetCurrent() };
|
||||
{
|
||||
let mut g = worker_runloop.lock().unwrap();
|
||||
let mut g = worker_runloop.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*g = Some(RunLoopHandle(runloop));
|
||||
}
|
||||
unsafe {
|
||||
|
||||
@@ -174,7 +174,7 @@ impl AudioCallback for TsPlaybackCallback {
|
||||
// in the cpal path, but the upstream design has shipped
|
||||
// this way for years.
|
||||
{
|
||||
let mut data = self.handler.lock().unwrap();
|
||||
let mut data = self.handler.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _removed_ids = data.fill_buffer(buffer);
|
||||
// `_removed_ids` is the list of clients whose stream the
|
||||
// handler just finished draining. We could publish that
|
||||
|
||||
@@ -89,7 +89,8 @@ unsafe fn resolve_symbol(name: &'static [u8]) -> Option<*mut c_void> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 16 kHz detector backed by Swift `SileroCoreML.SileroVAD`.
|
||||
/// 16 kHz detector backed by Swift Silero CoreML VAD.
|
||||
/// Processes 16 kHz frames and outputs speech probability.
|
||||
pub struct AppleCoreMlVad {
|
||||
handle: *mut c_void,
|
||||
symbols: AppleSileroSymbols,
|
||||
|
||||
@@ -22,6 +22,7 @@ use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
pub use silero_onnx::SileroOnnxVad;
|
||||
|
||||
/// Voice activity detector output for one 10 ms frame.
|
||||
/// Contains speech probability and binary decision.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VadOutput {
|
||||
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
||||
@@ -36,15 +37,19 @@ pub trait VoiceActivityDetector: Send {
|
||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
||||
}
|
||||
|
||||
/// Realtime-safe WebRTC VAD used when a model runtime is unavailable.
|
||||
/// Realtime-safe WebRTC VAD fallback when ONNX runtime is unavailable.
|
||||
/// Uses aggressive mode at 48 kHz for voice detection.
|
||||
pub struct WebRtcFallbackVad {
|
||||
vad: webrtc_vad::Vad,
|
||||
frame_i16: [i16; INPUT_FRAME_10MS],
|
||||
}
|
||||
|
||||
// `webrtc_vad::Vad` owns an FFI pointer and is only touched from the
|
||||
// capture thread after construction. Moving the wrapper between threads is
|
||||
// safe; sharing it concurrently is not required and not implemented.
|
||||
// SAFETY: `webrtc_vad::Vad` wraps an opaque FFI pointer to the WebRTC C VAD
|
||||
// state. The underlying C struct has no interior mutability that would cause
|
||||
// data races when moved between threads — `WebRtcVad_Process()` reads/writes
|
||||
// the struct exclusively through the passed pointer with no shared static state.
|
||||
// This wrapper is only used from a single capture thread after construction;
|
||||
// we never share `&WebRtcFallbackVad` across threads (no `Sync` impl).
|
||||
unsafe impl Send for WebRtcFallbackVad {}
|
||||
|
||||
impl Default for WebRtcFallbackVad {
|
||||
@@ -72,8 +77,8 @@ impl VoiceActivityDetector for WebRtcFallbackVad {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps any `VoiceActivityDetector` that operates at 16 kHz and
|
||||
/// downsamples 48 kHz input before forwarding.
|
||||
/// Wraps any `VoiceActivityDetector` operating at 16 kHz,
|
||||
/// downsampling 48 kHz input before forwarding to the detector.
|
||||
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
||||
inner: D,
|
||||
downsampler: Downsampler48to16,
|
||||
@@ -250,7 +255,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_rejects_missing_file() {
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
clear_silero_model_path_for_test();
|
||||
|
||||
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||
@@ -261,7 +266,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_updates_override_and_epoch() {
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
clear_silero_model_path_for_test();
|
||||
|
||||
let path =
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Poisoned-mutex survival test for audio callbacks (TODO-016).
|
||||
//!
|
||||
//! Verifies that the `unwrap_or_else(|e| e.into_inner())` recovery
|
||||
//! pattern used throughout chanora_audio produces usable values
|
||||
//! rather than panicking when a mutex is poisoned.
|
||||
|
||||
use std::panic;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Simulates a simple config guard behind a mutex, matching the
|
||||
/// pattern used by `audio_processing_config` in the engine.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct DummyConfig {
|
||||
gain: f32,
|
||||
muted: bool,
|
||||
}
|
||||
|
||||
impl Default for DummyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gain: 1.0,
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poisons a `Mutex<T>` by panicking while holding its lock,
|
||||
/// then catches the panic so the test can continue.
|
||||
fn poison_mutex<T: Default>(mx: &Mutex<T>) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let _guard = mx.lock().unwrap();
|
||||
panic!("deliberate poison");
|
||||
}));
|
||||
}
|
||||
|
||||
/// Helper that mirrors the exact recovery pattern used in production:
|
||||
/// `lock().unwrap_or_else(|e| e.into_inner())`.
|
||||
fn recover<T>(mx: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mx.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: Basic poison recovery returns the inner value.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn poison_recovery_returns_inner_value() {
|
||||
let mx = Mutex::new(DummyConfig::default());
|
||||
{
|
||||
let mut g = mx.lock().unwrap();
|
||||
g.gain = 0.5;
|
||||
g.muted = true;
|
||||
}
|
||||
poison_mutex(&mx);
|
||||
assert!(mx.is_poisoned());
|
||||
|
||||
let guard = recover(&mx);
|
||||
assert_eq!(guard.gain, 0.5);
|
||||
assert!(guard.muted);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: Recovered guard is mutable and usable (simulates an audio
|
||||
// callback writing silence to the output buffer after recovery).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recovered_guard_is_mutable() {
|
||||
let mx: Mutex<Vec<f32>> = Mutex::new(vec![0.0; 256]);
|
||||
{
|
||||
let mut g = mx.lock().unwrap();
|
||||
g.fill(0.75);
|
||||
}
|
||||
poison_mutex(&mx);
|
||||
|
||||
{
|
||||
let mut guard = recover(&mx);
|
||||
guard.fill(0.0);
|
||||
}
|
||||
|
||||
let guard = recover(&mx);
|
||||
assert!(guard.iter().all(|&s| s == 0.0), "expected silence after recovery");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: Multi-lock scenario — recover from a poisoned mutex, mutate
|
||||
// it, and verify subsequent reads see the updated state.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn recovered_state_persists_across_locks() {
|
||||
let mx = Mutex::new(42u32);
|
||||
poison_mutex(&mx);
|
||||
|
||||
*recover(&mx) = 99;
|
||||
|
||||
assert_eq!(*recover(&mx), 99);
|
||||
assert!(mx.is_poisoned(), "mutex stays poisoned but remains usable");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: Arc<Mutex<T>> pattern — mirrors the audio engine's shared
|
||||
// state where multiple callbacks hold Arc clones.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn shared_arc_mutex_recovery() {
|
||||
let mx = Arc::new(Mutex::new(DummyConfig::default()));
|
||||
{
|
||||
let mut g = mx.lock().unwrap();
|
||||
g.gain = 0.8;
|
||||
}
|
||||
poison_mutex(&mx);
|
||||
|
||||
let mx2 = Arc::clone(&mx);
|
||||
let guard = mx2.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert_eq!(guard.gain, 0.8);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: Snapshot-then-clone pattern — mirrors the engine's
|
||||
// `audio_processing_config_snapshot()` which clones through the guard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn snapshot_clone_through_poisoned_mutex() {
|
||||
let mx = Mutex::new(DummyConfig {
|
||||
gain: 0.42,
|
||||
muted: true,
|
||||
});
|
||||
poison_mutex(&mx);
|
||||
|
||||
let snapshot = mx.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
assert_eq!(snapshot.gain, 0.42);
|
||||
assert!(snapshot.muted);
|
||||
|
||||
let snapshot2 = mx.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
assert_eq!(snapshot, snapshot2);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ struct RecordingLayer {
|
||||
|
||||
impl RecordingLayer {
|
||||
fn snapshot(&self) -> Vec<Captured> {
|
||||
self.records.lock().unwrap().clone()
|
||||
self.records.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ where
|
||||
target: event.metadata().target().to_string(),
|
||||
field_names: names.0,
|
||||
};
|
||||
self.records.lock().unwrap().push(captured);
|
||||
self.records.lock().unwrap_or_else(|e| e.into_inner()).push(captured);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,3 +36,6 @@ ndk-context = "0.1"
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# chanora_bridge
|
||||
|
||||
Typed Flutter/Rust bridge — schema-controlled DTOs for commands, results, and events. Backed by `flutter_rust_bridge` 2.x per DEC-014.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`api` module** — all public functions exposed to Dart. Each function runs on a shared tokio runtime and delegates to `chanora_core::ChanoraSession`. Input/output types are owned primitives or `String`s — no backend types cross the boundary (SAD-067, SDD-079).
|
||||
- **`frb_generated`** — auto-generated `flutter_rust_bridge` glue. Contains `unsafe` for the FFI boundary; hand-written code must not use `unsafe`.
|
||||
- **`android_init`** (Android only) — NDK context initialization
|
||||
- **`permission_jni`** (Android only) — JNI hook for Android permission state changes (SDD-106)
|
||||
|
||||
### DTO pattern
|
||||
|
||||
Every Dart-facing type is a `Bridge*` DTO with primitive fields. `From` impls convert between bridge DTOs and `chanora_core` types. Most types do not carry `serde` derives — FRB generates its own SSE encoders/decoders.
|
||||
|
||||
### Event streaming
|
||||
|
||||
`BridgeEvent` enum is streamed to Dart via FRB's `StreamSink`. Events include: Connected, Disconnected, Lost, Reconnecting, ChatMessage, VoiceState, AudioStarted/Stopped, ClientJoined/Left/Moved/Updated, ChannelAdded/Removed/Updated, PttCapability, PermissionState, ServerActivity, InterruptionState, AudioRouteChanged.
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Commands (api.rs)
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `bridge_init()` | One-time init: logging, panic hook |
|
||||
| `connect(host, nickname, password)` | Connect to a server |
|
||||
| `disconnect()` | Clean disconnect |
|
||||
| `snapshot()` | Refresh server state |
|
||||
| `client_profile(client_id)` | Rich profile for one client |
|
||||
| `is_connected()` | Connection check |
|
||||
| `prefetch_server(host)` | Warm server resolution |
|
||||
| `voice_join(channel_id, password)` | Join voice channel |
|
||||
| `voice_leave()` | Leave voice channel |
|
||||
| `set_transmit_mode(mode)` | Ptt/Continuous/VoiceActivity |
|
||||
| `get_transmit_mode()` | Read current mode |
|
||||
| `set_hard_mute(muted)` | Hard-mute clamp |
|
||||
| `set_ptt(active)` | Manual PTT press/release |
|
||||
| `set_ptt_binding(input_class, platform_key)` | Bind a PTT key |
|
||||
| `ptt_descriptor()` | Current PTT capability |
|
||||
| `get_ptt_binding()` | Persisted PTT binding |
|
||||
| `set_release_tail_ms(ms)` | Release-tail config |
|
||||
| `get_release_tail_ms()` | Read release-tail |
|
||||
| `move_to_channel(channel_id, password)` | Move to a channel |
|
||||
| `set_input_muted(muted)` / `set_output_muted(muted)` | Server-side mute |
|
||||
| `set_output_gain(gain)` | Master volume |
|
||||
| `set_client_volume(client_id, volume)` | Per-client volume |
|
||||
| `send_chat_message(message, target)` | Send text |
|
||||
| `set_audio_processing_config(config)` | P1 audio processing |
|
||||
| `get_audio_processing_config()` | Read P1 config |
|
||||
| `audio_processing_stats()` | P1 telemetry |
|
||||
| `enable_audio_debug_wav_dump(enabled)` | WAV dump toggle |
|
||||
| `set_vad_model_path(path)` | Silero model path |
|
||||
| `set_input_device(id)` / `set_output_device(id)` | Device selection |
|
||||
| `export_diagnostics()` | Redacted export bundle (includes network stats) |
|
||||
| `audio_stats()` | Audio subsystem statistics |
|
||||
| `input_level_stream()` | Mic level metering stream |
|
||||
| `events_stream()` | Bridge event stream |
|
||||
| `log_file_path_str()` | Log file path for platform |
|
||||
| `init_storage()` / `init_cache()` | Storage/cache initialization |
|
||||
| `set_ios_voice_processing_mode(mode)` | iOS audio processing mode |
|
||||
| `set_audio_output_route(route)` | Audio output route selection |
|
||||
| `list_audio_devices()` | Enumerate audio devices |
|
||||
| `list_bookmarks()` / `add_bookmark` / `update_bookmark` / `delete_bookmark` | Bookmark CRUD |
|
||||
| `download_avatar(hash, uid)` / `download_icon(id)` | Avatar/icon download |
|
||||
| `clear_file_cache()` / `file_cache_size()` | Cache management |
|
||||
| `handle_route_change(route)` | iOS audio route change |
|
||||
| `handle_media_services_reset_with_route(route_class)` | iOS media reset |
|
||||
| `handle_interruption_began()` / `handle_interruption_ended(should_resume)` | iOS interruption |
|
||||
| `lifecycle_event(state)` | Platform lifecycle |
|
||||
|
||||
### Bridge DTOs
|
||||
|
||||
`BridgeSnapshot`, `BridgeChannel`, `BridgeClient`, `BridgeClientProfile`, `BridgeAudioStats`, `BridgeAudioProcessingConfig`, `BridgeAudioProcessingStats`, `BridgeAudioRoute`, `BridgeTransmitMode`, `BridgePttInputClass`, `BridgePttDescriptor`, `BridgePttBinding`, `BridgeMessageTarget`, `BridgeBookmark`, `PermissionStateKind`, `BridgeError`.
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Cannot use `#![forbid(unsafe_code)]` because FRB-generated glue legitimately uses `unsafe` for the FFI boundary.
|
||||
- Android: includes `android_init` and `permission_jni` modules gated behind `cfg(target_os = "android")`.
|
||||
- iOS: route-change and interruption handlers are synchronous (`#[frb(sync)]`), dispatched to the tokio runtime via an ordered channel.
|
||||
@@ -603,6 +603,8 @@ pub async fn connect(
|
||||
nickname: String,
|
||||
password: String,
|
||||
) -> Result<BridgeSnapshot, BridgeError> {
|
||||
let nickname = chanora_core::validate_nickname(&nickname)
|
||||
.map_err(|e| BridgeError::InvalidCommand(e.to_string()))?;
|
||||
let cfg = chanora_core::ConnectConfig {
|
||||
address: host,
|
||||
nickname,
|
||||
@@ -837,6 +839,15 @@ pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current hard-mute state. Returns `true` when the audio
|
||||
/// engine is clamped and transmits nothing regardless of mode.
|
||||
pub async fn is_hard_muted() -> bool {
|
||||
runtime()
|
||||
.spawn(async { session().hard_mute() })
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
|
||||
/// the bridge never carries raw key codes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -999,6 +1010,15 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current master output gain. Returns `1.0` (unity) when
|
||||
/// audio is not started.
|
||||
pub async fn get_output_gain() -> f32 {
|
||||
runtime()
|
||||
.spawn(async { session().output_gain().await })
|
||||
.await
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
|
||||
/// mutes. No-op when client has no active voice queue. Volume is
|
||||
/// applied directly to the tsclientlib AudioQueue and takes effect
|
||||
@@ -2394,7 +2414,7 @@ pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeErro
|
||||
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
|
||||
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
|
||||
.map_err(|e| BridgeError::unmapped_ctx("enable_audio_debug_wav_dump", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,15 @@ use thiserror::Error;
|
||||
/// Errors raised at the bridge boundary. Production code must keep
|
||||
/// these user-safe — no secrets, no protocol details, no path
|
||||
/// information beyond what the redaction policy permits.
|
||||
#[derive(Debug, Error, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Error, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BridgeError {
|
||||
/// The caller submitted a malformed command DTO.
|
||||
#[error("invalid command: {0}")]
|
||||
InvalidCommand(String),
|
||||
// TODO(refactor): DnsFailed and ServerRejected mirror ProtocolError variants
|
||||
// in chanora_protocol. These cannot be unified without changing the public FFI
|
||||
// API (flutter_rust_bridge generates Dart types from these). Revisit only if
|
||||
// the bridge error types are being reworked.
|
||||
/// Hostname resolution failed. Distinct from `Connection` so the
|
||||
/// UI can show a meaningful "Server not found" message.
|
||||
#[error("dns: could not resolve '{host}': {reason}")]
|
||||
@@ -94,6 +98,12 @@ pub enum BridgeError {
|
||||
Unmapped(String),
|
||||
}
|
||||
|
||||
impl BridgeError {
|
||||
fn unmapped_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
BridgeError::Unmapped(format!("{ctx}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::CoreError> for BridgeError {
|
||||
fn from(e: chanora_core::CoreError) -> Self {
|
||||
match e {
|
||||
@@ -121,3 +131,308 @@ impl From<chanora_core::CoreError> for BridgeError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn roundtrip_json<
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||
>(
|
||||
value: &T,
|
||||
) {
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
let back: T = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(&back, value, "roundtrip failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_invalid_command() {
|
||||
let err = BridgeError::InvalidCommand("bad".to_string());
|
||||
assert_eq!(err.to_string(), "invalid command: bad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_dns_failed() {
|
||||
let err = BridgeError::DnsFailed {
|
||||
host: "example.com".to_string(),
|
||||
reason: "timeout".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("example.com"));
|
||||
assert!(msg.contains("timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_connection() {
|
||||
let err = BridgeError::Connection("refused".to_string());
|
||||
assert_eq!(err.to_string(), "connection: refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_not_connected() {
|
||||
let err = BridgeError::NotConnected;
|
||||
assert_eq!(err.to_string(), "not connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_already_connected() {
|
||||
let err = BridgeError::AlreadyConnected;
|
||||
assert_eq!(err.to_string(), "already connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_server_rejected() {
|
||||
let err = BridgeError::ServerRejected {
|
||||
code: 2568,
|
||||
message: "insufficient permissions".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("2568"));
|
||||
assert!(msg.contains("insufficient permissions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_unmapped() {
|
||||
let err = BridgeError::Unmapped("mystery".to_string());
|
||||
assert_eq!(err.to_string(), "unmapped: mystery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_serde_roundtrip() {
|
||||
roundtrip_json(&BridgeError::InvalidCommand("test".to_string()));
|
||||
roundtrip_json(&BridgeError::NotConnected);
|
||||
roundtrip_json(&BridgeError::AlreadyConnected);
|
||||
roundtrip_json(&BridgeError::Connection("fail".to_string()));
|
||||
roundtrip_json(&BridgeError::Unmapped("x".to_string()));
|
||||
roundtrip_json(&BridgeError::DnsFailed {
|
||||
host: "h".to_string(),
|
||||
reason: "r".to_string(),
|
||||
});
|
||||
roundtrip_json(&BridgeError::ServerRejected {
|
||||
code: 42,
|
||||
message: "nope".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_error_clone_preserves() {
|
||||
let err = BridgeError::InvalidCommand("orig".to_string());
|
||||
let cloned = err.clone();
|
||||
assert_eq!(cloned.to_string(), err.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_not_connected() {
|
||||
let core_err = chanora_core::CoreError::NotConnected;
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
assert!(matches!(bridge_err, BridgeError::NotConnected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_already_connected() {
|
||||
let core_err = chanora_core::CoreError::AlreadyConnected;
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
assert!(matches!(bridge_err, BridgeError::AlreadyConnected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_audio_not_started() {
|
||||
let core_err = chanora_core::CoreError::AudioNotStarted;
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::InvalidCommand(msg) => {
|
||||
assert!(msg.contains("audio not started"));
|
||||
}
|
||||
other => panic!("expected InvalidCommand, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_dns_failed() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::DnsFailed {
|
||||
host: "bad.host".to_string(),
|
||||
reason: "no address".to_string(),
|
||||
},
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::DnsFailed { host, reason } => {
|
||||
assert_eq!(host, "bad.host");
|
||||
assert_eq!(reason, "no address");
|
||||
}
|
||||
other => panic!("expected DnsFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_server_rejected() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::ServerRejected {
|
||||
code: 0x0501,
|
||||
message: "channel password wrong".to_string(),
|
||||
},
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::ServerRejected { code, message } => {
|
||||
assert_eq!(code, 0x0501);
|
||||
assert_eq!(message, "channel password wrong");
|
||||
}
|
||||
other => panic!("expected ServerRejected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_file_transfer() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::FileTransfer("disk full".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("file transfer"));
|
||||
assert!(msg.contains("disk full"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_generic() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::Connect("refused".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("refused"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_lost() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::Lost("timeout".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("timeout"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_invalid() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::Invalid("bad config".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("bad config"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_disconnected_early() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::DisconnectedEarly("premature".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("premature"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_identity() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::Identity("parse error".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("parse error"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_timeout() {
|
||||
let core_err =
|
||||
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::Timeout);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("timeout"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_protocol_backend() {
|
||||
let core_err = chanora_core::CoreError::Protocol(
|
||||
chanora_core::ProtocolError::Backend("raw".to_string()),
|
||||
);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Connection(msg) => {
|
||||
assert!(msg.contains("raw"));
|
||||
}
|
||||
other => panic!("expected Connection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_core_error_invariant() {
|
||||
let core_err = chanora_core::CoreError::Invariant("broken");
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
match bridge_err {
|
||||
BridgeError::Unmapped(msg) => {
|
||||
assert!(msg.contains("broken"));
|
||||
}
|
||||
other => panic!("expected Unmapped, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_surfaces_all_protocol_error_variants() {
|
||||
let protocol_errors: Vec<chanora_core::ProtocolError> = vec![
|
||||
chanora_core::ProtocolError::Invalid("x".into()),
|
||||
chanora_core::ProtocolError::DnsFailed {
|
||||
host: "h".into(),
|
||||
reason: "r".into(),
|
||||
},
|
||||
chanora_core::ProtocolError::Connect("c".into()),
|
||||
chanora_core::ProtocolError::DisconnectedEarly("d".into()),
|
||||
chanora_core::ProtocolError::Lost("l".into()),
|
||||
chanora_core::ProtocolError::Identity("i".into()),
|
||||
chanora_core::ProtocolError::Timeout,
|
||||
chanora_core::ProtocolError::ServerRejected {
|
||||
code: 1,
|
||||
message: "m".into(),
|
||||
},
|
||||
chanora_core::ProtocolError::Backend("b".into()),
|
||||
chanora_core::ProtocolError::FileTransfer("f".into()),
|
||||
];
|
||||
for p_err in protocol_errors {
|
||||
let core_err = chanora_core::CoreError::Protocol(p_err);
|
||||
let bridge_err: BridgeError = core_err.into();
|
||||
let msg = bridge_err.to_string();
|
||||
assert!(!msg.is_empty(), "BridgeError message must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# chanora_cache
|
||||
|
||||
Disposable content-addressed blob cache for avatar and icon files. Wraps `cacache` for crash safety and integrity verification. Separated from `chanora_storage` because cache owns reconstructible, disposable blob data with different durability and backup semantics.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`BlobCache`** — async blob store backed by cacache's content-v2 / index-v2 on-disk layout.
|
||||
- Keys are protocol identifiers prefixed by type: `av_<32-char-hex>` for avatars (MD5), `ic_<decimal>` for icons (CRC32).
|
||||
- Cacache handles dedup and SSRI integrity verification on every read.
|
||||
- Corrupt entries are automatically removed on read failure.
|
||||
- LRU eviction by timestamp when total size exceeds the configured cap.
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `BlobCache` | Content-addressed blob cache |
|
||||
| `BlobCacheError` | Io, InvalidKey |
|
||||
|
||||
### Key methods on `BlobCache`
|
||||
|
||||
- `new(cache_dir, max_bytes)` — create or open the cache. `max_bytes = 0` disables eviction.
|
||||
- `put(prefix, key, data)` — store a blob (async)
|
||||
- `get(prefix, key)` → `Option<Vec<u8>>` — read a blob, with integrity check (async)
|
||||
- `remove(prefix, key)` — delete a specific blob (async)
|
||||
- `clear()` — delete all blobs (async)
|
||||
- `total_size()` → `u64` — sum of all blob sizes (async)
|
||||
- `evict()` — remove oldest entries until under `max_bytes` cap (async)
|
||||
|
||||
### Constants
|
||||
|
||||
- `PREFIX_AVATAR` = `"av_"` — avatar key prefix
|
||||
- `PREFIX_ICON` = `"ic_"` — icon key prefix
|
||||
|
||||
## Key validation
|
||||
|
||||
Avatar keys must be exactly 32 hex characters. Icon keys must be non-empty decimal digits. Unknown prefixes are rejected. This prevents malformed entries from polluting the cache.
|
||||
@@ -16,6 +16,9 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Errors raised by the blob cache.
|
||||
// TODO(refactor): Io(String) variant is duplicated across chanora_cache,
|
||||
// chanora_storage, and chanora_diagnostics. Could use a shared error type
|
||||
// or derive From<std::io::Error> instead of manually wrapping.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BlobCacheError {
|
||||
/// Filesystem I/O error.
|
||||
@@ -26,6 +29,12 @@ pub enum BlobCacheError {
|
||||
InvalidKey(String),
|
||||
}
|
||||
|
||||
impl BlobCacheError {
|
||||
fn io_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
BlobCacheError::Io(format!("{ctx}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-addressed blob cache backed by cacache.
|
||||
pub struct BlobCache {
|
||||
cache_dir: PathBuf,
|
||||
@@ -48,7 +57,7 @@ impl BlobCache {
|
||||
// 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}")))?;
|
||||
.map_err(|e| BlobCacheError::io_ctx("mkdir cache", e))?;
|
||||
Ok(Self {
|
||||
cache_dir,
|
||||
max_bytes,
|
||||
@@ -70,7 +79,7 @@ impl BlobCache {
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
cacache::write(&self.cache_dir, &cache_key, data)
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("cacache write: {e}")))?;
|
||||
.map_err(|e| BlobCacheError::io_ctx("cacache write", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -103,7 +112,7 @@ impl BlobCache {
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
cacache::remove(&self.cache_dir, &cache_key)
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("cacache remove: {e}")))?;
|
||||
.map_err(|e| BlobCacheError::io_ctx("cacache remove", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -113,14 +122,14 @@ impl BlobCache {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if path.exists() {
|
||||
std::fs::remove_dir_all(&path)
|
||||
.map_err(|e| BlobCacheError::Io(format!("clear cache: {e}")))?;
|
||||
.map_err(|e| BlobCacheError::io_ctx("clear cache", e))?;
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| BlobCacheError::Io(format!("recreate cache dir: {e}")))?;
|
||||
.map_err(|e| BlobCacheError::io_ctx("recreate cache dir", e))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("clear task: {e}")))?
|
||||
.map_err(|e| BlobCacheError::io_ctx("clear task", e))?
|
||||
}
|
||||
|
||||
/// Return total bytes used by all blobs.
|
||||
@@ -145,7 +154,7 @@ impl BlobCache {
|
||||
Ok(total)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("total_size task: {e}")))?
|
||||
.map_err(|e| BlobCacheError::io_ctx("total_size task", e))?
|
||||
}
|
||||
|
||||
/// Evict oldest entries by timestamp until total size is under
|
||||
@@ -195,7 +204,7 @@ impl BlobCache {
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("evict task: {e}")))?
|
||||
.map_err(|e| BlobCacheError::io_ctx("evict task", e))?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# chanora_diagnostics
|
||||
|
||||
Application diagnostics: log redaction, in-memory log capture, and user-initiated diagnostic export. Per DEC-016, export is **user-initiated only**; there is no automatic upload.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Redaction policy
|
||||
|
||||
`Redactor` applies the production policy (REDACT-TC-001..010):
|
||||
|
||||
1. Known-secret registry — substring match → `[REDACTED]`
|
||||
2. `$HOME` prefix → `[home]`
|
||||
3. IPv4 addresses → `[ip]`
|
||||
4. IPv6 addresses → `[ip]`
|
||||
5. Email-shaped strings → `[email]`
|
||||
6. Long opaque tokens (base64 ≥32 chars, ≥75% alnum) → `[token]`
|
||||
|
||||
### PTT sanitiser
|
||||
|
||||
`PttSanitizer<L>` — a `tracing-subscriber` Layer decorator that drops any record containing field names from the banned list (`key_code`, `scan_code`, `virtual_key`, `keysym`, etc.) per DEC-027 / REDACT-PTT-001..006. Allocation-free on the success path.
|
||||
|
||||
### Log capture
|
||||
|
||||
`InMemoryLogSink` — bounded ring buffer that passes every line through the redactor before storing. Capacity differs by build: 4096 lines (debug), 256 lines (release) per SRS-122.
|
||||
|
||||
### Event recorder
|
||||
|
||||
`ProtocolEventRecorder` — ring buffer of protocol-level events (connect, disconnect, reconnect, snapshot changes, channel joins) for diagnostic export and state-sync replay verification (SRS-097/098).
|
||||
|
||||
### Export
|
||||
|
||||
`DiagnosticExport` — serialisable bundle containing:
|
||||
- Client metadata (version, platform)
|
||||
- Redacted recent logs
|
||||
- Known-secret count (values never exported)
|
||||
- Optional Android audio diagnostics YAML
|
||||
- Optional network diagnostics summary
|
||||
- Protocol event trace
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `Redactor` | Production redaction policy (cheap to clone) |
|
||||
| `KnownSecretRegistry` | Cross-spike secret registry for defence in depth (SS-AUD-003) |
|
||||
| `InMemoryLogSink` | Bounded ring buffer of redacted log lines |
|
||||
| `RedactingLogLayer` | `tracing-subscriber` Layer feeding `InMemoryLogSink` |
|
||||
| `PttSanitizer<L>` | Layer decorator dropping PTT-sensitive records |
|
||||
| `DiagnosticExport` | User-facing export bundle |
|
||||
| `ProtocolEventRecorder` | Protocol event ring buffer (SRS-097) |
|
||||
| `DiagnosticsError` | Export, Io |
|
||||
| `REDACTION_MARKER` | `"[REDACTED]"` |
|
||||
|
||||
### Key methods
|
||||
|
||||
**Redactor:**
|
||||
- `with_default_policy()` / `with_secrets(registry)` — construct
|
||||
- `redact(s)` → `String` — apply policy
|
||||
- `secrets()` → `&KnownSecretRegistry` — register secrets
|
||||
|
||||
**KnownSecretRegistry:**
|
||||
- `register(secret)` — add a known-secret value (≥4 chars)
|
||||
- `contains_substr(haystack)` → `bool` — substring check
|
||||
|
||||
**InMemoryLogSink:**
|
||||
- `new(capacity, redactor)` — construct
|
||||
- `push(raw)` — redact and store a line
|
||||
- `snapshot()` → `Vec<String>` — current buffer contents
|
||||
|
||||
**DiagnosticExport:**
|
||||
- `from_sink(sink, metadata)` — build from log sink
|
||||
- `with_android_audio(yaml)` / `with_network_info(info)` / `with_protocol_events(events)` — attach optional sections
|
||||
- `to_text()` → `String` — render as multi-line plaintext
|
||||
|
||||
**ProtocolEventRecorder:**
|
||||
- `new(capacity)` — construct
|
||||
- `record_connected(server_name)` / `record_disconnected(reason)` / `record_reconnecting(attempt, delay)`
|
||||
- `drain()` → `Vec<String>` / `snapshot()` → `Vec<String>`
|
||||
@@ -0,0 +1,48 @@
|
||||
# chanora_prefetch
|
||||
|
||||
Server-address prefetch cache and policy. Owns speculative server-resolution warming so that when the user presses Connect, a fresh DNS/SRV result may already be available, reducing perceived join latency.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Cache model
|
||||
|
||||
`ServerPrefetchCache` holds at most one entry — the latest prefetched resolution. A generation counter prevents stale async completions from overwriting newer results. TTL is 120 seconds.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Flutter typing triggers `prefetch_server(host)` via the bridge.
|
||||
2. `ServerPrefetcher::prefetch()` normalizes the host, bumps the generation, and spawns a fire-and-forget tokio task that calls `chanora_resolver::ChanoraResolver::resolve_client_address()`.
|
||||
3. On success, the result is stored if its generation is still current.
|
||||
4. When `chanora_core::connect()` is called, it checks `fresh_match(host)`. If a fresh (non-expired) entry matches, it's used as the `resolved_address` in `ConnectConfig`, bypassing a second DNS round trip.
|
||||
|
||||
### Generation guard
|
||||
|
||||
If the user types another host while the first prefetch is in flight, the generation advances. The slower completion is discarded because its generation no longer matches. The most recent entry always wins.
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `ServerPrefetcher` | Public API: schedule prefetches, query fresh matches |
|
||||
| `ServerPrefetchError` | ResolverInit, Resolution, InvalidSocketAddress |
|
||||
|
||||
### Key methods on `ServerPrefetcher`
|
||||
|
||||
- `new()` — construct with empty cache
|
||||
- `prefetch(host)` — schedule a fire-and-forget resolution (async). Only reports synchronous setup failures; DNS failures are logged.
|
||||
- `fresh_match(host)` → `Option<SocketAddr>` — return a cached address if it matches and is within TTL (async)
|
||||
|
||||
### Test-only methods (behind `cfg(test)` or `feature = "test-support"`)
|
||||
|
||||
- `begin_for_test(host)` — bump generation
|
||||
- `store_success_for_test(generation, host, addr, instant)` — inject a result
|
||||
- `latest_generation_for_test()` — read current generation
|
||||
- `fail_next_prefetch_setup_for_test(error)` — inject a setup failure
|
||||
|
||||
## Design notes
|
||||
|
||||
- Blank/whitespace-only hosts are silently skipped.
|
||||
- Hosts are normalized to lowercase trimmed strings before matching.
|
||||
- A fresh entry remains usable while a newer prefetch is in flight; stale completions are rejected by the generation guard.
|
||||
@@ -194,6 +194,9 @@ impl ServerPrefetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(refactor): `normalize_host` duplicates `chanora_resolver::normalize_args`
|
||||
// host trimming. Both do `host.trim().to_lowercase()`. Could move to a shared
|
||||
// utility in chanora_protocol or a tiny chanora_common crate if more crates need it.
|
||||
fn normalize_host(host: &str) -> String {
|
||||
host.trim().to_lowercase()
|
||||
}
|
||||
|
||||
@@ -42,3 +42,6 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", "
|
||||
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
|
||||
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# chanora_protocol
|
||||
|
||||
TeamSpeak-compatible protocol adapter. Isolates `tsclientlib` behind a typed boundary so the rest of Chanora is decoupled from the upstream library's types (SAD-067).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`adapter` module** — wraps `tsclientlib::Connection` into an async `ProtocolClient` handle. Owns the connection task, loss notifier, snapshot probe, and voice channel endpoints.
|
||||
- **`dto` module** — plain-data types (`ServerSnapshot`, `ChannelInfo`, `ClientInfo`, `ClientProfile`, `ChatMessage`) containing only `String`s and primitives. No `tsclientlib` types leak out.
|
||||
- **`poke_limiter`** — rate-limiter for poke messages to prevent spam.
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `ProtocolClient` | Async handle owning the TS3 connection task |
|
||||
| `ConnectConfig` | Connection parameters: address, nickname, password, identity, timeout, resolved_address |
|
||||
| `ServerSnapshot` | Full server state: channels, clients, metadata |
|
||||
| `ChannelInfo` / `ClientInfo` | Channel and client DTOs |
|
||||
| `ClientProfile` | Rich per-client profile (unique_id, country, ping, groups, etc.) |
|
||||
| `ChatMessage` | Inbound text message with target enum |
|
||||
| `MessageTarget` | Server / Channel / Client(id) / Poke(id) |
|
||||
| `ProtocolDelta` | Live state changes: client joined/left/moved/updated, channel added/removed/updated |
|
||||
| `ServerActivity` | Server-wide broadcast messages |
|
||||
| `DisconnectReason` | UserRequested / StreamEnded / Error |
|
||||
| `ProtocolError` | Typed error catalogue: Invalid, DnsFailed, Connect, Lost, Identity, Timeout, ServerRejected, FileTransfer |
|
||||
| `PokeLimiter` | Rate-limiting poke sends |
|
||||
|
||||
### Key methods on `ProtocolClient`
|
||||
|
||||
- `connect(cfg)` — dial a server and return a connected client
|
||||
- `snapshot()` — fetch current server state
|
||||
- `client_profile(id)` — rich profile for one client
|
||||
- `send_text_message(msg, target)` — send chat
|
||||
- `move_to_channel(id, password)` — move to a channel
|
||||
- `queue_move_to_channel(id, password)` — async move with typed error reply
|
||||
- `set_muted(input, output)` — server-side mute
|
||||
- `download_avatar(uid)` / `download_icon(id)` — fetch protocol-owned assets
|
||||
- `voice_out()` / `take_voice_in()` — voice packet endpoints
|
||||
- `take_loss_notifier()` — oneshot channel that fires on connection loss
|
||||
- `snapshot_probe()` — watchdog probe handle
|
||||
- `generate_identity()` — create a fresh TS3 identity string
|
||||
- `disconnect()` — clean shutdown
|
||||
|
||||
### Re-exports
|
||||
|
||||
The crate deliberately re-exports `tsproto_packets::packets::{AudioData, CodecType, Direction, InAudioBuf, OutAudio, OutPacket}` — the single permitted exception so `chanora_audio` can build voice packets without a direct `tsclientlib` dependency (SAD-067 performance carve-out).
|
||||
|
||||
## Address resolution
|
||||
|
||||
`chanora_resolver` owns TeamSpeak client address resolution (SRV, TSDNS, DNS fallback). This crate feeds the resulting `SocketAddr` to `tsclientlib::Connection::build`, bypassing tsclientlib's own resolver.
|
||||
@@ -40,8 +40,8 @@ use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPack
|
||||
use tsproto_types::ClientType;
|
||||
|
||||
use crate::dto::{
|
||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
validate_nickname, ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile,
|
||||
MessageTarget, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
use crate::poke_limiter::PokeLimiter;
|
||||
use crate::ProtocolError;
|
||||
@@ -138,7 +138,26 @@ where
|
||||
/// `Version` enum at compile time; if upstream rotates the CSV the
|
||||
/// build will fail loudly here rather than silently fall back.
|
||||
fn pick_client_version() -> Version {
|
||||
Version::Windows_3_X_X__1
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Version::Windows_5_0_0_beta51
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Version::Linux_5_0_0_beta51
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Version::macOS_5_0_0_beta51
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
Version::Android_3_5_0__7
|
||||
}
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
Version::iOS_3_5_6
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed configuration for a connection attempt.
|
||||
@@ -280,9 +299,9 @@ impl SnapshotProbe {
|
||||
self.tx
|
||||
.send(Request::Snapshot(tx))
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("snapshot reply dropped"))?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,9 +324,10 @@ impl ProtocolClient {
|
||||
if cfg.address.trim().is_empty() {
|
||||
return Err(ProtocolError::Invalid("address is empty".to_string()));
|
||||
}
|
||||
if cfg.nickname.trim().is_empty() {
|
||||
return Err(ProtocolError::Invalid("nickname is empty".to_string()));
|
||||
}
|
||||
let validated_nick = validate_nickname(&cfg.nickname)
|
||||
.map_err(|e| ProtocolError::Invalid(e.to_string()))?;
|
||||
let mut cfg = cfg;
|
||||
cfg.nickname = validated_nick;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Request>(8);
|
||||
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
|
||||
@@ -356,9 +376,9 @@ impl ProtocolClient {
|
||||
self.tx
|
||||
.send(Request::Snapshot(tx))
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("snapshot reply dropped"))?
|
||||
}
|
||||
|
||||
/// Fetch richer profile and live connection details for one online client.
|
||||
@@ -370,9 +390,9 @@ impl ProtocolClient {
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("client_profile reply dropped"))?
|
||||
}
|
||||
|
||||
async fn download_file(&self, path: String) -> Result<Vec<u8>, ProtocolError> {
|
||||
@@ -380,9 +400,9 @@ impl ProtocolClient {
|
||||
self.tx
|
||||
.send(Request::DownloadFile { path, reply: tx })
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("download_file reply dropped"))?
|
||||
}
|
||||
|
||||
/// Download the current avatar bytes for a TeamSpeak client UID.
|
||||
@@ -431,9 +451,9 @@ impl ProtocolClient {
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("move_to_channel reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("move_to_channel reply dropped"))?
|
||||
}
|
||||
|
||||
/// Queue a move command and return once it has been accepted by
|
||||
@@ -450,7 +470,7 @@ impl ProtocolClient {
|
||||
password,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))
|
||||
}
|
||||
|
||||
/// Update mute state on our own client. Pass `Some(_)` for the
|
||||
@@ -468,9 +488,9 @@ impl ProtocolClient {
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("set_muted reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("set_muted reply dropped"))?
|
||||
}
|
||||
|
||||
/// Sender for outbound voice packets. Clone freely.
|
||||
@@ -563,17 +583,21 @@ impl ProtocolClient {
|
||||
message: String,
|
||||
target: MessageTarget,
|
||||
) -> Result<(), ProtocolError> {
|
||||
let validated = match target {
|
||||
MessageTarget::Poke(_) => crate::dto::validate_poke_message(&message),
|
||||
_ => crate::dto::validate_message(&message),
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::SendTextMessage {
|
||||
message,
|
||||
message: validated,
|
||||
target,
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("send_text_message reply dropped".to_string()))?
|
||||
.map_err(|_| ProtocolError::lost("send_text_message reply dropped"))?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,7 +1176,7 @@ fn move_self_to(
|
||||
) -> Result<MessageHandle, ProtocolError> {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
let own_id = state.own_client;
|
||||
let own_client = state
|
||||
.clients
|
||||
@@ -1165,7 +1189,7 @@ fn move_self_to(
|
||||
}
|
||||
let handle = part
|
||||
.send_with_result(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("client_move send: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("client_move send", e))?;
|
||||
info!(target: "chanora_protocol", channel_id, "client_move sent");
|
||||
Ok(handle)
|
||||
}
|
||||
@@ -1183,7 +1207,7 @@ fn set_self_muted(
|
||||
let part = {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
let mut p = state.client_update();
|
||||
if let Some(v) = input {
|
||||
p = p.set_input_muted(v);
|
||||
@@ -1194,7 +1218,7 @@ fn set_self_muted(
|
||||
p
|
||||
};
|
||||
part.send(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("client_update send: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("client_update send", e))?;
|
||||
info!(target: "chanora_protocol", ?input, ?output, "client_update sent");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1220,22 +1244,22 @@ fn send_text_message(
|
||||
MessageTarget::Client(client_id) => {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
let client = find_client_by_id(state.clients.values(), client_id)?;
|
||||
client
|
||||
.send_textmessage(message)
|
||||
.send(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("send_textmessage(client)", e))?;
|
||||
}
|
||||
MessageTarget::Poke(client_id) => {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
let client = find_client_by_id(state.clients.values(), client_id)?;
|
||||
client
|
||||
.poke(message)
|
||||
.send(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("poke", e))?;
|
||||
}
|
||||
}
|
||||
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
|
||||
@@ -1256,7 +1280,7 @@ fn send_text_to_mode(
|
||||
message: message.into(),
|
||||
}))
|
||||
.send(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
|
||||
.map_err(|e| ProtocolError::backend_ctx(format!("send_textmessage({label})"), e))
|
||||
}
|
||||
|
||||
async fn fetch_client_profile(
|
||||
@@ -1281,7 +1305,7 @@ async fn fetch_client_profile(
|
||||
) = {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
let client = state
|
||||
.clients
|
||||
.get(&target_id)
|
||||
@@ -1392,7 +1416,7 @@ async fn fetch_client_profile(
|
||||
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
let client = state
|
||||
.clients
|
||||
.get(&target_id)
|
||||
@@ -1550,7 +1574,7 @@ async fn request_messages(
|
||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||
let handle = command
|
||||
.send_with_result(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("send command: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("send command", e))?;
|
||||
let mut messages = Vec::new();
|
||||
let deadline = Instant::now() + PROFILE_REFRESH_RESULT_TIMEOUT;
|
||||
loop {
|
||||
@@ -1804,7 +1828,7 @@ fn build_snapshot(
|
||||
) -> Result<ServerSnapshot, ProtocolError> {
|
||||
let state: &data::Connection = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||
|
||||
// TeamSpeak channel ordering: the `order` field on a channel is
|
||||
// NOT a numeric rank but the id of the channel that should
|
||||
|
||||
@@ -5,6 +5,58 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::poke_limiter::PokeStrength;
|
||||
|
||||
/// TS3 protocol limits for outbound fields.
|
||||
pub const MAX_NICKNAME_LEN: usize = 30;
|
||||
pub const MAX_MESSAGE_LEN: usize = 1024;
|
||||
pub const MAX_POKE_LEN: usize = 100;
|
||||
pub const MAX_CHANNEL_NAME_LEN: usize = 40;
|
||||
|
||||
/// Truncate `s` to at most `max_len` UTF-8 characters, splitting at a
|
||||
/// char boundary if needed. Returns the (possibly shortened) string.
|
||||
pub fn validate_and_truncate(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
return s.to_string();
|
||||
}
|
||||
// Find the last char boundary at or before max_len.
|
||||
let mut end = max_len;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
/// Validate a nickname: trim whitespace, reject empty, truncate to
|
||||
/// [`MAX_NICKNAME_LEN`].
|
||||
pub fn validate_nickname(nick: &str) -> Result<String, &'static str> {
|
||||
let trimmed = nick.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("nickname must not be empty");
|
||||
}
|
||||
Ok(validate_and_truncate(trimmed, MAX_NICKNAME_LEN))
|
||||
}
|
||||
|
||||
/// Validate a chat message: truncate to [`MAX_MESSAGE_LEN`]. Empty
|
||||
/// messages are allowed (poke-without-message is valid per DEC-037).
|
||||
pub fn validate_message(msg: &str) -> String {
|
||||
validate_and_truncate(msg, MAX_MESSAGE_LEN)
|
||||
}
|
||||
|
||||
/// Validate a poke message: truncate to [`MAX_POKE_LEN`]. Empty
|
||||
/// messages are allowed.
|
||||
pub fn validate_poke_message(msg: &str) -> String {
|
||||
validate_and_truncate(msg, MAX_POKE_LEN)
|
||||
}
|
||||
|
||||
/// Validate a channel name: trim whitespace, reject empty, truncate
|
||||
/// to [`MAX_CHANNEL_NAME_LEN`].
|
||||
pub fn validate_channel_name(name: &str) -> Result<String, &'static str> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("channel name must not be empty");
|
||||
}
|
||||
Ok(validate_and_truncate(trimmed, MAX_CHANNEL_NAME_LEN))
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -262,3 +314,550 @@ pub enum ProtocolDelta {
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn roundtrip_json<T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug>(
|
||||
value: &T,
|
||||
) {
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
let back: T = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(&back, value, "roundtrip failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_id_serde_roundtrip() {
|
||||
roundtrip_json(&ChannelId(0));
|
||||
roundtrip_json(&ChannelId(1));
|
||||
roundtrip_json(&ChannelId(u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_id_serde_roundtrip() {
|
||||
roundtrip_json(&ClientId(0));
|
||||
roundtrip_json(&ClientId(42));
|
||||
roundtrip_json(&ClientId(u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_id_root_is_zero() {
|
||||
assert_eq!(ChannelId::ROOT, ChannelId(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_serde_roundtrip() {
|
||||
let info = ChannelInfo {
|
||||
id: ChannelId(1),
|
||||
parent: ChannelId(0),
|
||||
name: "General".to_string(),
|
||||
order: 0,
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
};
|
||||
roundtrip_json(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_with_all_fields() {
|
||||
let info = ChannelInfo {
|
||||
id: ChannelId(99),
|
||||
parent: ChannelId(5),
|
||||
name: "AFK".to_string(),
|
||||
order: -1,
|
||||
has_password: true,
|
||||
needed_talk_power: Some(75),
|
||||
};
|
||||
roundtrip_json(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_empty_name() {
|
||||
let info = ChannelInfo {
|
||||
id: ChannelId(1),
|
||||
parent: ChannelId(0),
|
||||
name: String::new(),
|
||||
order: 0,
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
};
|
||||
roundtrip_json(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_info_unicode_name() {
|
||||
let info = ChannelInfo {
|
||||
id: ChannelId(1),
|
||||
parent: ChannelId(0),
|
||||
name: "🎮 Spielsaal 🎮".to_string(),
|
||||
order: 0,
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
};
|
||||
roundtrip_json(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_target_serde_roundtrip() {
|
||||
roundtrip_json(&MessageTarget::Server);
|
||||
roundtrip_json(&MessageTarget::Channel);
|
||||
roundtrip_json(&MessageTarget::Client(12345));
|
||||
roundtrip_json(&MessageTarget::Poke(67890));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_target_json_shape() {
|
||||
let json = serde_json::to_string(&MessageTarget::Server).unwrap();
|
||||
assert_eq!(json, "\"Server\"");
|
||||
|
||||
let json = serde_json::to_string(&MessageTarget::Client(42)).unwrap();
|
||||
assert!(json.contains("\"Client\""));
|
||||
assert!(json.contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_serde_roundtrip() {
|
||||
let msg = ChatMessage {
|
||||
sender_id: ClientId(1),
|
||||
sender_name: "Alice".to_string(),
|
||||
message: "Hello world".to_string(),
|
||||
target: MessageTarget::Channel,
|
||||
poke_strength: None,
|
||||
};
|
||||
roundtrip_json(&msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_with_poke_strength() {
|
||||
let msg = ChatMessage {
|
||||
sender_id: ClientId(5),
|
||||
sender_name: "Bob".to_string(),
|
||||
message: "".to_string(),
|
||||
target: MessageTarget::Poke(99),
|
||||
poke_strength: Some(PokeStrength::Strong),
|
||||
};
|
||||
roundtrip_json(&msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_unicode_content() {
|
||||
let msg = ChatMessage {
|
||||
sender_id: ClientId(1),
|
||||
sender_name: "日本語ネーム".to_string(),
|
||||
message: "🎉 こんにちは世界 🌍".to_string(),
|
||||
target: MessageTarget::Server,
|
||||
poke_strength: None,
|
||||
};
|
||||
roundtrip_json(&msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_activity_serde_roundtrip() {
|
||||
let act = ServerActivity {
|
||||
message: "User joined".to_string(),
|
||||
};
|
||||
roundtrip_json(&act);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_activity_empty_message() {
|
||||
let act = ServerActivity {
|
||||
message: String::new(),
|
||||
};
|
||||
roundtrip_json(&act);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_info_serde_roundtrip() {
|
||||
let info = ClientInfo {
|
||||
id: ClientId(1),
|
||||
channel: ChannelId(2),
|
||||
name: "Player".to_string(),
|
||||
input_muted: false,
|
||||
output_muted: true,
|
||||
is_speaking: false,
|
||||
is_server_query: false,
|
||||
talk_power: 0,
|
||||
talk_power_granted: false,
|
||||
};
|
||||
roundtrip_json(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_info_server_query_with_talk_power() {
|
||||
let info = ClientInfo {
|
||||
id: ClientId(100),
|
||||
channel: ChannelId(3),
|
||||
name: "Bot".to_string(),
|
||||
input_muted: true,
|
||||
output_muted: true,
|
||||
is_speaking: false,
|
||||
is_server_query: true,
|
||||
talk_power: 75,
|
||||
talk_power_granted: true,
|
||||
};
|
||||
roundtrip_json(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_snapshot_serde_roundtrip() {
|
||||
let snap = ServerSnapshot {
|
||||
server_name: "Test Server".to_string(),
|
||||
welcome_message: "Welcome!".to_string(),
|
||||
platform: "Linux".to_string(),
|
||||
version: "3.13.7".to_string(),
|
||||
channels: vec![
|
||||
ChannelInfo {
|
||||
id: ChannelId(1),
|
||||
parent: ChannelId(0),
|
||||
name: "Root".to_string(),
|
||||
order: 0,
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
},
|
||||
],
|
||||
clients: vec![
|
||||
ClientInfo {
|
||||
id: ClientId(1),
|
||||
channel: ChannelId(1),
|
||||
name: "User1".to_string(),
|
||||
input_muted: false,
|
||||
output_muted: false,
|
||||
is_speaking: false,
|
||||
is_server_query: false,
|
||||
talk_power: 0,
|
||||
talk_power_granted: false,
|
||||
},
|
||||
],
|
||||
own_client_id: 1,
|
||||
};
|
||||
roundtrip_json(&snap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_snapshot_empty_channels_and_clients() {
|
||||
let snap = ServerSnapshot {
|
||||
server_name: String::new(),
|
||||
welcome_message: String::new(),
|
||||
platform: String::new(),
|
||||
version: String::new(),
|
||||
channels: vec![],
|
||||
clients: vec![],
|
||||
own_client_id: 0,
|
||||
};
|
||||
roundtrip_json(&snap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_profile_serde_roundtrip() {
|
||||
let profile = ClientProfile {
|
||||
id: ClientId(1),
|
||||
channel: ChannelId(2),
|
||||
name: "Player".to_string(),
|
||||
unique_id: "abc123".to_string(),
|
||||
database_id: Some(42),
|
||||
country_code: "DE".to_string(),
|
||||
description: String::new(),
|
||||
version: "3.5.0".to_string(),
|
||||
platform: "Windows".to_string(),
|
||||
created_unix_seconds: Some(1609459200),
|
||||
last_connected_unix_seconds: Some(1700000000),
|
||||
connections_total: Some(100),
|
||||
online_seconds: Some(3600),
|
||||
idle_milliseconds: Some(500),
|
||||
ping_milliseconds: Some(42),
|
||||
ping_deviation_milliseconds: Some(5),
|
||||
client_address: String::new(),
|
||||
server_groups: vec!["Admin".to_string(), "Mod".to_string()],
|
||||
channel_group: "Channel Admin".to_string(),
|
||||
avatar_path: String::new(),
|
||||
bytes_downloaded_month: Some(1024),
|
||||
bytes_uploaded_month: Some(512),
|
||||
bytes_downloaded_total: Some(4096),
|
||||
bytes_uploaded_total: Some(2048),
|
||||
packet_loss_client_to_server_total: Some(0.01),
|
||||
packet_loss_server_to_client_total: Some(0.02),
|
||||
};
|
||||
roundtrip_json(&profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_profile_minimal_fields() {
|
||||
let profile = ClientProfile {
|
||||
id: ClientId(1),
|
||||
channel: ChannelId(0),
|
||||
name: String::new(),
|
||||
unique_id: String::new(),
|
||||
database_id: None,
|
||||
country_code: String::new(),
|
||||
description: String::new(),
|
||||
version: String::new(),
|
||||
platform: String::new(),
|
||||
created_unix_seconds: None,
|
||||
last_connected_unix_seconds: None,
|
||||
connections_total: None,
|
||||
online_seconds: None,
|
||||
idle_milliseconds: None,
|
||||
ping_milliseconds: None,
|
||||
ping_deviation_milliseconds: None,
|
||||
client_address: String::new(),
|
||||
server_groups: vec![],
|
||||
channel_group: String::new(),
|
||||
avatar_path: String::new(),
|
||||
bytes_downloaded_month: None,
|
||||
bytes_uploaded_month: None,
|
||||
bytes_downloaded_total: None,
|
||||
bytes_uploaded_total: None,
|
||||
packet_loss_client_to_server_total: None,
|
||||
packet_loss_server_to_client_total: None,
|
||||
};
|
||||
roundtrip_json(&profile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_client_moved() {
|
||||
let delta = ProtocolDelta::ClientMoved {
|
||||
client_id: 1,
|
||||
new_channel_id: 2,
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_client_joined() {
|
||||
let delta = ProtocolDelta::ClientJoined {
|
||||
client_id: 5,
|
||||
channel_id: 3,
|
||||
name: "NewUser".to_string(),
|
||||
input_muted: false,
|
||||
output_muted: false,
|
||||
is_server_query: false,
|
||||
talk_power: 0,
|
||||
talk_power_granted: false,
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_client_left() {
|
||||
let delta = ProtocolDelta::ClientLeft {
|
||||
client_id: 5,
|
||||
name: "Departing".to_string(),
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_client_updated() {
|
||||
let delta = ProtocolDelta::ClientUpdated {
|
||||
client_id: 10,
|
||||
input_muted: true,
|
||||
output_muted: false,
|
||||
is_server_query: false,
|
||||
talk_power: 50,
|
||||
talk_power_granted: true,
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_channel_added() {
|
||||
let delta = ProtocolDelta::ChannelAdded {
|
||||
id: 7,
|
||||
parent: 1,
|
||||
name: "New Channel".to_string(),
|
||||
order: 5,
|
||||
has_password: true,
|
||||
needed_talk_power: Some(25),
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_channel_removed() {
|
||||
let delta = ProtocolDelta::ChannelRemoved { id: 7 };
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_channel_updated() {
|
||||
let delta = ProtocolDelta::ChannelUpdated {
|
||||
id: 7,
|
||||
name: "Renamed".to_string(),
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_unicode_names() {
|
||||
let delta = ProtocolDelta::ClientJoined {
|
||||
client_id: 1,
|
||||
channel_id: 1,
|
||||
name: "ユーザー".to_string(),
|
||||
input_muted: false,
|
||||
output_muted: false,
|
||||
is_server_query: false,
|
||||
talk_power: 0,
|
||||
talk_power_granted: false,
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_delta_boundary_values() {
|
||||
let delta = ProtocolDelta::ChannelAdded {
|
||||
id: u64::MAX,
|
||||
parent: u64::MAX,
|
||||
name: String::new(),
|
||||
order: i64::MIN,
|
||||
has_password: true,
|
||||
needed_talk_power: Some(i32::MAX),
|
||||
};
|
||||
roundtrip_json(&delta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poke_strength_serde_roundtrip() {
|
||||
roundtrip_json(&PokeStrength::Strong);
|
||||
roundtrip_json(&PokeStrength::Suppressed);
|
||||
roundtrip_json(&PokeStrength::SuppressedOverflow);
|
||||
}
|
||||
|
||||
// ── validation tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_and_truncate_short_string_unchanged() {
|
||||
assert_eq!(validate_and_truncate("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_truncate_exact_boundary() {
|
||||
assert_eq!(validate_and_truncate("12345", 5), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_truncate_truncates_ascii() {
|
||||
assert_eq!(validate_and_truncate("hello world", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_truncate_respects_char_boundary() {
|
||||
// é is 2 bytes; truncating at byte 1 would panic without
|
||||
// char-boundary logic.
|
||||
let s = "aé";
|
||||
// s.len() == 3 (a=1, é=2). max_len=2 → must drop é.
|
||||
assert_eq!(validate_and_truncate(s, 2), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_truncate_emoji_multibyte() {
|
||||
let s = "🎮🎮🎮";
|
||||
// Each emoji is 4 bytes. max_len=5 → only first emoji (4 bytes).
|
||||
assert_eq!(validate_and_truncate(s, 5), "🎮");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_truncate_empty() {
|
||||
assert_eq!(validate_and_truncate("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_nickname_ok() {
|
||||
assert_eq!(validate_nickname("Alice").unwrap(), "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_nickname_trims_whitespace() {
|
||||
assert_eq!(validate_nickname(" Bob ").unwrap(), "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_nickname_rejects_empty() {
|
||||
assert!(validate_nickname("").is_err());
|
||||
assert!(validate_nickname(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_nickname_truncates_long() {
|
||||
let long = "A".repeat(100);
|
||||
let result = validate_nickname(&long).unwrap();
|
||||
assert!(result.len() <= MAX_NICKNAME_LEN);
|
||||
assert_eq!(result.len(), MAX_NICKNAME_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_message_short_unchanged() {
|
||||
assert_eq!(validate_message("hi"), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_message_empty_allowed() {
|
||||
assert_eq!(validate_message(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_message_truncates_long() {
|
||||
let long = "x".repeat(2000);
|
||||
let result = validate_message(&long);
|
||||
assert!(result.len() <= MAX_MESSAGE_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_poke_message_truncates_to_shorter_limit() {
|
||||
let long = "y".repeat(200);
|
||||
let result = validate_poke_message(&long);
|
||||
assert!(result.len() <= MAX_POKE_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_poke_message_empty_allowed() {
|
||||
assert_eq!(validate_poke_message(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_channel_name_ok() {
|
||||
assert_eq!(validate_channel_name("General").unwrap(), "General");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_channel_name_trims_whitespace() {
|
||||
assert_eq!(validate_channel_name(" AFK ").unwrap(), "AFK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_channel_name_rejects_empty() {
|
||||
assert!(validate_channel_name("").is_err());
|
||||
assert!(validate_channel_name(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_channel_name_truncates_long() {
|
||||
let long = "C".repeat(100);
|
||||
let result = validate_channel_name(&long).unwrap();
|
||||
assert!(result.len() <= MAX_CHANNEL_NAME_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_info_equality_and_clone() {
|
||||
let a = ClientId(42);
|
||||
let b = a;
|
||||
assert_eq!(a, b);
|
||||
let c = ClientId(42);
|
||||
assert_eq!(a, c);
|
||||
let d = ClientId(43);
|
||||
assert_ne!(a, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_id_hash_consistency() {
|
||||
use std::collections::HashSet;
|
||||
let mut set = HashSet::new();
|
||||
set.insert(ChannelId(1));
|
||||
set.insert(ChannelId(1));
|
||||
set.insert(ChannelId(2));
|
||||
assert_eq!(set.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Client-side anti-flood awareness per YaTQA §5.1.
|
||||
//!
|
||||
//! TS3 servers enforce a tick-based point system. Points accumulate
|
||||
//! per operation and decay over time. This tracker provides client-side
|
||||
//! awareness to avoid accidental server bans.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// Point costs per operation (from YaTQA §5.2).
|
||||
///
|
||||
/// These are client-side estimates. Server may differ slightly.
|
||||
/// Zero-cost operations are listed for completeness.
|
||||
pub struct FloodCosts;
|
||||
|
||||
impl FloodCosts {
|
||||
// Zero-cost
|
||||
/// Client disconnect (0 points).
|
||||
pub const CLIENT_DISCONNECT: u32 = 0;
|
||||
/// Get client variables (0 points).
|
||||
pub const CLIENT_GET_VARIABLES: u32 = 0;
|
||||
/// Set whisper list (0 points).
|
||||
pub const SET_WHISPER_LIST: u32 = 0;
|
||||
/// File transfer get file list (0 points).
|
||||
pub const FT_GET_FILE_LIST: u32 = 0;
|
||||
/// File transfer init upload (0 points).
|
||||
pub const FT_INIT_UPLOAD: u32 = 0;
|
||||
/// File transfer init download (0 points).
|
||||
pub const FT_INIT_DOWNLOAD: u32 = 0;
|
||||
|
||||
// Low-cost (5)
|
||||
/// Add permission (5 points).
|
||||
pub const PERMISSION_ADD: u32 = 5;
|
||||
/// Remove permission (5 points).
|
||||
pub const PERMISSION_REMOVE: u32 = 5;
|
||||
/// Add server group (5 points).
|
||||
pub const SERVER_GROUP_ADD: u32 = 5;
|
||||
/// Delete server group (5 points).
|
||||
pub const SERVER_GROUP_DELETE: u32 = 5;
|
||||
|
||||
// Medium-cost (10-20)
|
||||
/// Move client to another channel (10 points).
|
||||
pub const CLIENT_MOVE: u32 = 10;
|
||||
/// Send text message (15 points).
|
||||
pub const TEXT_MESSAGE_SEND: u32 = 15;
|
||||
/// Subscribe to channel (158 points).
|
||||
pub const CHANNEL_SUBSCRIBE: u32 = 158;
|
||||
/// Set badges on connect (15 points).
|
||||
pub const SET_BADGES: u32 = 15;
|
||||
|
||||
// High-cost (25)
|
||||
/// Add ban (25 points).
|
||||
pub const BAN_ADD: u32 = 25;
|
||||
/// Ban client (25 points).
|
||||
pub const BAN_CLIENT: u32 = 25;
|
||||
/// Add complain (25 points).
|
||||
pub const COMPLAIN_ADD: u32 = 25;
|
||||
/// Delete all complains (25 points).
|
||||
pub const COMPLAIN_DEL_ALL: u32 = 25;
|
||||
/// Create channel (25 points).
|
||||
pub const CHANNEL_CREATE: u32 = 25;
|
||||
/// Delete channel (25 points).
|
||||
pub const CHANNEL_DELETE: u32 = 25;
|
||||
/// Move channel (25 points).
|
||||
pub const CHANNEL_MOVE: u32 = 25;
|
||||
/// Edit channel (25 points).
|
||||
pub const CHANNEL_EDIT: u32 = 25;
|
||||
/// Kick client (25 points).
|
||||
pub const CLIENT_KICK: u32 = 25;
|
||||
/// Poke client (25 points).
|
||||
pub const CLIENT_POKE: u32 = 25;
|
||||
/// Edit client (25 points).
|
||||
pub const CLIENT_EDIT: u32 = 25;
|
||||
/// Add client to server group (25 points).
|
||||
pub const SERVER_GROUP_ADD_CLIENT: u32 = 25;
|
||||
/// Remove client from server group (25 points).
|
||||
pub const SERVER_GROUP_DEL_CLIENT: u32 = 25;
|
||||
/// Set client channel group (25 points).
|
||||
pub const SET_CLIENT_CHANNEL_GROUP: u32 = 25;
|
||||
|
||||
// Very high-cost (50)
|
||||
/// Delete client from database (50 points).
|
||||
pub const CLIENT_DB_DELETE: u32 = 50;
|
||||
/// Edit client in database (50 points).
|
||||
pub const CLIENT_DB_EDIT: u32 = 50;
|
||||
/// Find client in database (50 points).
|
||||
pub const CLIENT_DB_FIND: u32 = 50;
|
||||
/// View server log (50 points).
|
||||
pub const LOG_VIEW: u32 = 50;
|
||||
}
|
||||
|
||||
/// Server-configured anti-flood parameters.
|
||||
///
|
||||
/// Obtained from `serverinfo` response. If unavailable,
|
||||
/// conservative defaults are used.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FloodConfig {
|
||||
/// Points deducted per 0.5-second tick.
|
||||
pub points_tick_reduce: u32,
|
||||
/// Points before command block (at equality).
|
||||
pub points_to_command_block: u32,
|
||||
/// Points before IP block.
|
||||
pub points_to_ip_block: u32,
|
||||
}
|
||||
|
||||
impl Default for FloodConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
points_tick_reduce: 25,
|
||||
points_to_command_block: 150,
|
||||
points_to_ip_block: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current flood risk level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum FloodRisk {
|
||||
/// Well below thresholds.
|
||||
Safe,
|
||||
/// Approaching command block (>= 80% of threshold).
|
||||
NearLimit,
|
||||
/// At or above command block threshold. Commands will be dropped.
|
||||
CommandBlocked,
|
||||
/// At or above IP block threshold. Connection may be terminated.
|
||||
IpBlocked,
|
||||
}
|
||||
|
||||
/// Client-side flood tracker following YaTQA §5.1 model.
|
||||
///
|
||||
/// Tick interval: 0.5 seconds. Points decay by `config.points_tick_reduce`
|
||||
/// per tick. Thresholds are server-configurable.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust
|
||||
/// use chanora_protocol::flood_tracker::{FloodTracker, FloodCosts, FloodConfig, FloodRisk};
|
||||
///
|
||||
/// let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
/// let risk = tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||
/// if risk >= FloodRisk::NearLimit {
|
||||
/// // Warn user or throttle operations
|
||||
/// }
|
||||
/// ```
|
||||
pub struct FloodTracker {
|
||||
points: u32,
|
||||
last_tick: Instant,
|
||||
config: FloodConfig,
|
||||
}
|
||||
|
||||
impl FloodTracker {
|
||||
/// Tick interval in milliseconds (0.5 seconds per YaTQA §5.1).
|
||||
const TICK_INTERVAL_MS: u128 = 500;
|
||||
|
||||
/// Create a new tracker with the given server configuration.
|
||||
pub fn new(config: FloodConfig) -> Self {
|
||||
Self {
|
||||
points: 0,
|
||||
last_tick: Instant::now(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an operation and return the current flood risk.
|
||||
pub fn record(&mut self, cost: u32) -> FloodRisk {
|
||||
self.tick();
|
||||
self.points = self.points.saturating_add(cost);
|
||||
self.risk_level()
|
||||
}
|
||||
|
||||
/// Apply time-based decay (0.5-second ticks).
|
||||
fn tick(&mut self) {
|
||||
let elapsed = self.last_tick.elapsed();
|
||||
let ticks = (elapsed.as_millis() / Self::TICK_INTERVAL_MS) as u32;
|
||||
if ticks > 0 {
|
||||
let decay = ticks.saturating_mul(self.config.points_tick_reduce);
|
||||
self.points = self.points.saturating_sub(decay);
|
||||
self.last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Current risk level based on accumulated points.
|
||||
pub fn risk_level(&self) -> FloodRisk {
|
||||
if self.points >= self.config.points_to_ip_block {
|
||||
FloodRisk::IpBlocked
|
||||
} else if self.points >= self.config.points_to_command_block {
|
||||
FloodRisk::CommandBlocked
|
||||
} else if self.points >= (self.config.points_to_command_block * 80 / 100) {
|
||||
FloodRisk::NearLimit
|
||||
} else {
|
||||
FloodRisk::Safe
|
||||
}
|
||||
}
|
||||
|
||||
/// Current accumulated points.
|
||||
pub fn points(&self) -> u32 {
|
||||
self.points
|
||||
}
|
||||
|
||||
/// Update configuration from serverinfo response.
|
||||
pub fn update_config(&mut self, config: FloodConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// Reset points (e.g., after successful reconnect).
|
||||
pub fn reset(&mut self) {
|
||||
self.points = 0;
|
||||
self.last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_tracker_is_safe() {
|
||||
let tracker = FloodTracker::new(FloodConfig::default());
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||
assert_eq!(tracker.points(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_accumulation() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||
assert_eq!(tracker.points(), 15);
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_detection() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
// 80% of 150 = 120
|
||||
for _ in 0..8 {
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND); // 8 * 15 = 120
|
||||
}
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::NearLimit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_blocked_detection() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
// 150 / 15 = 10 messages
|
||||
for _ in 0..10 {
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||
}
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::CommandBlocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_cost_channel_subscribe() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
let risk = tracker.record(FloodCosts::CHANNEL_SUBSCRIBE);
|
||||
assert_eq!(tracker.points(), 158);
|
||||
assert_eq!(risk, FloodRisk::CommandBlocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_update() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
tracker.update_config(FloodConfig {
|
||||
points_tick_reduce: 10,
|
||||
points_to_command_block: 200,
|
||||
points_to_ip_block: 400,
|
||||
});
|
||||
// With higher threshold, same points should be safe
|
||||
for _ in 0..10 {
|
||||
tracker.record(FloodCosts::TEXT_MESSAGE_SEND); // 150
|
||||
}
|
||||
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_points() {
|
||||
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||
tracker.record(FloodCosts::CHANNEL_SUBSCRIBE);
|
||||
assert!(tracker.points() > 0);
|
||||
tracker.reset();
|
||||
assert_eq!(tracker.points(), 0);
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,13 @@
|
||||
|
||||
mod adapter;
|
||||
mod dto;
|
||||
pub mod flood_tracker;
|
||||
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,
|
||||
validate_nickname, ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile,
|
||||
MessageTarget, PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use poke_limiter::PokeLimiter;
|
||||
|
||||
@@ -120,3 +121,13 @@ pub enum ProtocolError {
|
||||
#[error("file transfer failed: {0}")]
|
||||
FileTransfer(String),
|
||||
}
|
||||
|
||||
impl ProtocolError {
|
||||
fn lost(msg: impl Into<String>) -> Self {
|
||||
ProtocolError::Lost(msg.into())
|
||||
}
|
||||
|
||||
fn backend_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
ProtocolError::Backend(format!("{ctx}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# chanora_state
|
||||
|
||||
Authoritative client-side mirror of server state: channel tree, client list, and connection lifecycle. Owns the reducers that fold protocol events into state and produce deltas for the bridge (per SAD §7.2 and SDD §5).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core reducer pattern
|
||||
|
||||
The crate exposes a single `reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction` function. Callers own state storage and pass it by `&mut`. The reducer returns a `Reduction` containing only the emitted `Delta` values. This satisfies:
|
||||
|
||||
- **SRS-056** — deterministic deltas: the same `(state, event)` always produces the same `Reduction`
|
||||
- **SRS-057** — per-connection ordering
|
||||
- **SRS-058** — reducer functions are pure
|
||||
|
||||
### Module: `channel_join`
|
||||
|
||||
A more specialized reducer for voice-channel join/leave state tracking with:
|
||||
- Optimistic `UserJoinRequested` events
|
||||
- `AuthoritativeSelfMove` confirmation from live deltas
|
||||
- `SnapshotReady` reconciliation after connects/reconnects
|
||||
- `ChannelJoinProjection` for UI rendering (in_channel, can_join, can_leave, sync_state)
|
||||
- `ConnectionEpoch` tracking to disambiguate stale events across reconnects
|
||||
|
||||
### State model
|
||||
|
||||
- `ServerState` — owned `HashMap<u64, ChannelInfo>` and `HashMap<u64, ClientInfo>` with stable ordering vectors. Built from `ServerSnapshot`, updated incrementally via `StateEvent`s.
|
||||
- `ConnectionState` — enum: Idle / Connecting / Ready / Reconnecting / Lost
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `ServerState` | Authoritative mirror of connected server state |
|
||||
| `ConnectionState` | Lifecycle enum (Idle, Connecting, Ready, Reconnecting, Lost) |
|
||||
| `StateEvent` | Protocol-layer input events (Snapshot, ChannelChanged, ClientChanged, etc.) |
|
||||
| `Delta` | Bridge output events (SnapshotApplied, ChannelUpserted, ClientRemoved, etc.) |
|
||||
| `Reduction` | Result of `reduce()`: a `Vec<Delta>` |
|
||||
| `StateError` | Reducer errors (Unknown entity, invariant violation) |
|
||||
|
||||
### Key functions
|
||||
|
||||
- `reduce(state, event)` → `Reduction` — apply a protocol event, return deltas
|
||||
- `reduce_reconnect_snapshot(state, snap)` → `Reduction` — replace all state on reconnect (SRS-059)
|
||||
|
||||
### `ServerState` methods
|
||||
|
||||
- `channel(id)` / `client(id)` — lookup by id
|
||||
- `channels()` / `clients()` — ordered iterators
|
||||
- `own_channel()` — the channel our client is in
|
||||
- `clients_in_channel(channel_id)` — filtered iterator
|
||||
|
||||
### `channel_join` module
|
||||
|
||||
- `reduce(state, event)` → `JoinReduction` — channel-join state machine
|
||||
- `project(state)` → `ChannelJoinProjection` — UI-ready snapshot
|
||||
- `ChannelJoinEvent`, `ChannelJoinState`, `ChannelJoinProjection` — state machine types
|
||||
|
||||
## Design notes
|
||||
|
||||
- Events are ignored when state is `None` (disconnected), except `Snapshot` (creates state) and `ConnectionChanged`.
|
||||
- Deleting a channel also removes all clients in that channel.
|
||||
- Duplicate IDs in snapshots are deduplicated deterministically.
|
||||
@@ -110,11 +110,6 @@ impl ServerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace state with a fresh snapshot (post-reconnect). Satisfies SRS-059.
|
||||
pub fn replace_from_snapshot(&mut self, snapshot: ServerSnapshot) {
|
||||
*self = Self::from_snapshot(snapshot);
|
||||
}
|
||||
|
||||
/// Look up a channel by id.
|
||||
pub fn channel(&self, id: ChannelId) -> Option<&ChannelInfo> {
|
||||
self.channels.get(&id.0)
|
||||
@@ -144,16 +139,6 @@ impl ServerState {
|
||||
.filter_map(|id| self.clients.get(id))
|
||||
}
|
||||
|
||||
/// Number of channels.
|
||||
pub fn channel_count(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
|
||||
/// Number of clients.
|
||||
pub fn client_count(&self) -> usize {
|
||||
self.clients.len()
|
||||
}
|
||||
|
||||
/// The channel our own client is currently in.
|
||||
pub fn own_channel(&self) -> Option<&ChannelInfo> {
|
||||
self.client(ClientId(self.own_client_id))
|
||||
@@ -202,6 +187,10 @@ fn normalize_snapshot(snapshot: ServerSnapshot) -> ServerSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(refactor): StateEvent and Delta have mirrored variants (e.g.
|
||||
// StateEvent::ChannelChanged/ChannelDeleted vs Delta::ChannelUpserted/ChannelRemoved).
|
||||
// A proc-macro or macro_rules could generate the Delta-from-StateEvent mapping, but
|
||||
// the manual match is currently clear and the types serve different roles (input vs output).
|
||||
/// A change to the server state that the bridge should publish to
|
||||
/// Flutter. Deltas are cheap to construct and carry only the
|
||||
/// information that changed.
|
||||
@@ -463,8 +452,8 @@ mod tests {
|
||||
assert!(state.is_some());
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.connection_state, ConnectionState::Ready);
|
||||
assert_eq!(s.channel_count(), 2);
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.channels().count(), 2);
|
||||
assert_eq!(s.clients().count(), 1);
|
||||
assert_eq!(s.own_client_id, 10);
|
||||
assert_eq!(
|
||||
reduction.deltas,
|
||||
@@ -489,7 +478,7 @@ mod tests {
|
||||
};
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelChanged(ch.clone()));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.channel_count(), 3);
|
||||
assert_eq!(s.channels().count(), 3);
|
||||
assert!(s.channel(ChannelId(3)).is_some());
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ChannelUpserted(_)]));
|
||||
let updated = ChannelInfo {
|
||||
@@ -501,7 +490,7 @@ mod tests {
|
||||
state.as_ref().unwrap().channel(ChannelId(3)).unwrap().name,
|
||||
"renamed"
|
||||
);
|
||||
assert_eq!(state.as_ref().unwrap().channel_count(), 3);
|
||||
assert_eq!(state.as_ref().unwrap().channels().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -510,7 +499,7 @@ mod tests {
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.channel_count(), 1);
|
||||
assert_eq!(s.channels().count(), 1);
|
||||
assert!(s.channel(ChannelId(2)).is_none());
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ChannelRemoved(_)]));
|
||||
}
|
||||
@@ -528,7 +517,7 @@ mod tests {
|
||||
assert!(s.channel(ChannelId(2)).is_none());
|
||||
assert!(s.client(ClientId(20)).is_none());
|
||||
assert!(s.client(ClientId(30)).is_none());
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.clients().count(), 1);
|
||||
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 0);
|
||||
assert_eq!(
|
||||
reduction.deltas,
|
||||
@@ -547,7 +536,7 @@ mod tests {
|
||||
let new_client = sample_client(20, 2);
|
||||
let reduction = reduce(&mut state, StateEvent::ClientChanged(new_client));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.client_count(), 2);
|
||||
assert_eq!(s.clients().count(), 2);
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ClientUpserted(_)]));
|
||||
let moved = ClientInfo {
|
||||
channel: ChannelId(2),
|
||||
@@ -570,7 +559,7 @@ mod tests {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ClientLeft(ClientId(10)));
|
||||
assert_eq!(state.as_ref().unwrap().client_count(), 0);
|
||||
assert_eq!(state.as_ref().unwrap().clients().count(), 0);
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ClientRemoved(_)]));
|
||||
}
|
||||
|
||||
@@ -578,7 +567,7 @@ mod tests {
|
||||
fn reconnect_discards_stale_state() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
|
||||
assert_eq!(state.as_ref().unwrap().channels().count(), 2);
|
||||
let reduction = reduce(&mut state, StateEvent::ReconnectStarted);
|
||||
assert!(state.is_none());
|
||||
assert!(matches!(
|
||||
@@ -594,8 +583,8 @@ mod tests {
|
||||
reduce_reconnect_snapshot(&mut state, snap2);
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.server_name, "New Server");
|
||||
assert_eq!(s.channel_count(), 1);
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.channels().count(), 1);
|
||||
assert_eq!(s.clients().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -685,9 +674,7 @@ mod tests {
|
||||
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot));
|
||||
let s = state.as_ref().unwrap();
|
||||
|
||||
assert_eq!(s.channel_count(), 2);
|
||||
assert_eq!(s.channels().count(), 2);
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.clients().count(), 1);
|
||||
assert_eq!(s.channel(ChannelId(1)).unwrap().name, "duplicate");
|
||||
assert_eq!(s.client(ClientId(10)).unwrap().channel, ChannelId(2));
|
||||
@@ -766,7 +753,7 @@ mod tests {
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(999)));
|
||||
assert!(reduction.deltas.is_empty());
|
||||
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
|
||||
assert_eq!(state.as_ref().unwrap().channels().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -795,12 +782,12 @@ mod tests {
|
||||
deltas_b.extend(reduce(&mut b, e.clone()).deltas);
|
||||
}
|
||||
assert_eq!(
|
||||
a.as_ref().unwrap().channel_count(),
|
||||
b.as_ref().unwrap().channel_count()
|
||||
a.as_ref().unwrap().channels().count(),
|
||||
b.as_ref().unwrap().channels().count()
|
||||
);
|
||||
assert_eq!(
|
||||
a.as_ref().unwrap().client_count(),
|
||||
b.as_ref().unwrap().client_count()
|
||||
a.as_ref().unwrap().clients().count(),
|
||||
b.as_ref().unwrap().clients().count()
|
||||
);
|
||||
assert_eq!(
|
||||
a.as_ref().unwrap().own_client_id,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# chanora_storage
|
||||
|
||||
Two strictly separated storage concerns per SAD-067:
|
||||
|
||||
1. **`BookmarkRepository`** — non-secret bookmark state via SQLite with optional encrypted password fields (`rusqlite` bundled, DEC-013.1).
|
||||
2. **`IdentityFileStore`** — Beta fallback storage for identity material while platform secure-storage backends mature.
|
||||
|
||||
## Architecture
|
||||
|
||||
### IdentityFileStore
|
||||
|
||||
- Persists a single TS3 identity to `<dir>/identity.tskey` encrypted with ChaCha20-Poly1305.
|
||||
- The Data Encryption Key (DEK) is 32 random bytes stored in the platform keyring (Linux Secret Service, macOS Keychain, Windows Credential Manager, iOS Keychain) when available, with a best-effort file fallback at `identity.dek` (mode 0600 on Unix).
|
||||
- Legacy plaintext files from pre-Beta are still readable; the next `save()` upgrades them to encrypted form.
|
||||
- Audio metadata (`transmit_mode`, `release_tail_ms`, PTT binding) is persisted alongside as `audio_meta.json` (plaintext, non-secret).
|
||||
|
||||
### BookmarkRepository
|
||||
|
||||
- SQLite-backed store at `<dir>/chanora.db`.
|
||||
- Schema v1: basic bookmark columns. Schema v2: adds `password_blob` for encrypted passwords.
|
||||
- When constructed via `with_crypto()`, the `password` column is replaced by a ChaCha20-Poly1305 envelope under the same per-install DEK.
|
||||
- Legacy plaintext passwords are transparently read and upgraded on the next `update()`.
|
||||
|
||||
### Crypto abstraction
|
||||
|
||||
- `Crypto` trait: `encrypt(plaintext)` / `decrypt(blob)` — callers see only the encrypt/decrypt pair.
|
||||
- `DekCrypto` — concrete implementation sharing the same per-install DEK with `IdentityFileStore`.
|
||||
|
||||
## Public API Summary
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `IdentityFileStore` | Encrypted identity file store |
|
||||
| `BookmarkRepository` | SQLite bookmark store with optional password encryption |
|
||||
| `Bookmark` | Bookmark DTO: id, display_name, host, nickname, password |
|
||||
| `PttBindingMeta` | Persisted PTT binding metadata |
|
||||
| `StorageError` | NotFound, Migration, Sqlite, SecureStore, Io, Crypto |
|
||||
| `Crypto` trait | Encrypt/decrypt abstraction |
|
||||
|
||||
### IdentityFileStore methods
|
||||
|
||||
- `new(dir)` — create or open store, ensure DEK exists
|
||||
- `load()` → `Option<String>` — read identity (handles legacy plaintext)
|
||||
- `save(identity)` — persist encrypted (ChaCha20-Poly1305, atomic write)
|
||||
- `clear()` — remove identity file
|
||||
- `crypto()` — obtain a `Crypto` handle sharing the DEK
|
||||
- `set_transmit_mode(mode)` / `get_transmit_mode()` — audio settings persistence
|
||||
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail persistence
|
||||
- `set_ptt_binding(...)` / `get_ptt_binding()` — PTT binding persistence
|
||||
|
||||
### BookmarkRepository methods
|
||||
|
||||
- `new(dir)` / `with_crypto(dir, crypto)` — open (plain or encrypted)
|
||||
- `add(bookmark)` → `i64` — insert, return id
|
||||
- `update(bookmark)` — replace by id
|
||||
- `delete(id)` — remove by id
|
||||
- `list()` → `Vec<Bookmark>` — all bookmarks ordered by id
|
||||
- `upsert_or_add(bookmark)` — insert or update by host, preserves user's display name
|
||||
- `encrypts_passwords()` — whether password encryption is active
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Unix: files written with mode 0600.
|
||||
- Keyring access can be disabled via `CHANORA_DISABLE_KEYRING=1` for tests/headless environments.
|
||||
- Android: file in app-private storage (not encrypted at rest — documented Beta gap).
|
||||
- iOS/Windows/macOS: caller provides the storage directory; platform sandbox handles access control.
|
||||
@@ -79,6 +79,28 @@ pub enum StorageError {
|
||||
Crypto(String),
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
fn io_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
StorageError::Io(format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
fn crypto_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
StorageError::Crypto(format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
fn sqlite_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
StorageError::Sqlite(format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
fn migration_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||
StorageError::Migration(format!("{ctx}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_dir(dir: &Path) -> Result<(), StorageError> {
|
||||
fs::create_dir_all(dir).map_err(|e| StorageError::io_ctx(format!("mkdir {dir:?}"), e))
|
||||
}
|
||||
|
||||
/// Audio-related per-identity settings persisted alongside the
|
||||
/// identity file as a small JSON blob (SDD-095 / SDD-096). These
|
||||
/// are *not* secrets; they sit beside the encrypted identity in
|
||||
@@ -193,7 +215,7 @@ impl IdentityFileStore {
|
||||
/// the DEK on first use; subsequent uses reuse the existing DEK.
|
||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
|
||||
let dir = dir.as_ref();
|
||||
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
||||
ensure_dir(dir)?;
|
||||
let canonical = fs::canonicalize(dir)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| dir.to_string_lossy().into_owned());
|
||||
@@ -242,7 +264,7 @@ impl IdentityFileStore {
|
||||
Ok(b64) => {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.as_bytes())
|
||||
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
|
||||
.map_err(|e| StorageError::crypto_ctx("keyring dek decode", e))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(StorageError::Crypto(format!(
|
||||
"keyring dek length {} (expected 32)",
|
||||
@@ -270,7 +292,9 @@ impl IdentityFileStore {
|
||||
target_os = "ios"
|
||||
)))]
|
||||
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
|
||||
Ok(None)
|
||||
Err(StorageError::SecureStore(
|
||||
"keyring is not yet supported on this platform".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Persist the DEK in the platform keyring. Returns true on
|
||||
@@ -353,9 +377,9 @@ impl IdentityFileStore {
|
||||
let _ = self.keyring_save(&key);
|
||||
let mut f = open_private(&self.dek_path)?;
|
||||
f.write_all(&key)
|
||||
.map_err(|e| StorageError::Io(format!("write dek: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("write dek", e))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| StorageError::Io(format!("sync dek: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("sync dek", e))?;
|
||||
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
|
||||
key.zeroize();
|
||||
Ok(())
|
||||
@@ -391,11 +415,11 @@ impl IdentityFileStore {
|
||||
let mut f = match fs::File::open(&self.path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(StorageError::Io(format!("open {:?}: {e}", self.path))),
|
||||
Err(e) => return Err(StorageError::io_ctx(format!("open {:?}", self.path), e)),
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
f.read_to_end(&mut buf)
|
||||
.map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?;
|
||||
.map_err(|e| StorageError::io_ctx(format!("read {:?}", self.path), e))?;
|
||||
if buf.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -414,11 +438,11 @@ impl IdentityFileStore {
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| {
|
||||
key_bytes.zeroize();
|
||||
StorageError::Crypto(format!("decrypt: {e}"))
|
||||
StorageError::crypto_ctx("decrypt", e)
|
||||
})?;
|
||||
key_bytes.zeroize();
|
||||
let s = String::from_utf8(pt)
|
||||
.map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?;
|
||||
.map_err(|e| StorageError::crypto_ctx("plaintext not utf8", e))?;
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -434,7 +458,7 @@ impl IdentityFileStore {
|
||||
"identity file is in legacy plaintext format; will encrypt on next save"
|
||||
);
|
||||
let s = String::from_utf8(buf)
|
||||
.map_err(|e| StorageError::Io(format!("legacy not utf8: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("legacy not utf8", e))?;
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
@@ -456,7 +480,7 @@ impl IdentityFileStore {
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher.encrypt(nonce, plaintext).map_err(|e| {
|
||||
key_bytes.zeroize();
|
||||
StorageError::Crypto(format!("encrypt: {e}"))
|
||||
StorageError::crypto_ctx("encrypt", e)
|
||||
})?;
|
||||
key_bytes.zeroize();
|
||||
|
||||
@@ -465,14 +489,14 @@ impl IdentityFileStore {
|
||||
{
|
||||
let mut f = open_private(&tmp)?;
|
||||
f.write_all(&nonce_bytes)
|
||||
.map_err(|e| StorageError::Io(format!("write nonce: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("write nonce", e))?;
|
||||
f.write_all(&ct)
|
||||
.map_err(|e| StorageError::Io(format!("write ct: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("write ct", e))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx(format!("sync {tmp:?}"), e))?;
|
||||
}
|
||||
fs::rename(&tmp, &self.path)
|
||||
.map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?;
|
||||
.map_err(|e| StorageError::io_ctx(format!("rename {tmp:?} -> {:?}", self.path), e))?;
|
||||
info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -501,17 +525,17 @@ impl IdentityFileStore {
|
||||
let path = self.meta_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let body = serde_json::to_vec_pretty(m)
|
||||
.map_err(|e| StorageError::Io(format!("meta serialize: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("meta serialize", e))?;
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.map_err(|e| StorageError::Io(format!("open meta {tmp:?}: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx(format!("open meta {tmp:?}"), e))?;
|
||||
f.write_all(&body)
|
||||
.map_err(|e| StorageError::Io(format!("write meta: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("write meta", e))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| StorageError::Io(format!("sync meta: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("sync meta", e))?;
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.map_err(|e| StorageError::Io(format!("rename meta {tmp:?} -> {path:?}: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx(format!("rename meta {tmp:?} -> {path:?}"), e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -617,10 +641,10 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
|
||||
/// and the legacy migration path inside `ensure_dek`.
|
||||
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
|
||||
let mut f =
|
||||
fs::File::open(path).map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
|
||||
fs::File::open(path).map_err(|e| StorageError::io_ctx(format!("open dek {path:?}"), e))?;
|
||||
let mut key = [0u8; 32];
|
||||
f.read_exact(&mut key)
|
||||
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
|
||||
.map_err(|e| StorageError::io_ctx("read dek", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -650,7 +674,7 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(p)
|
||||
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
||||
.map_err(|e| StorageError::io_ctx(format!("open {p:?}"), e))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -664,7 +688,7 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(p)
|
||||
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
||||
.map_err(|e| StorageError::io_ctx(format!("open {p:?}"), e))
|
||||
}
|
||||
|
||||
/// Public abstraction over the per-install envelope-encryption
|
||||
@@ -719,7 +743,7 @@ impl DekCrypto {
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?;
|
||||
.map_err(|e| StorageError::crypto_ctx("encrypt", e))?;
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ct);
|
||||
@@ -738,7 +762,7 @@ impl DekCrypto {
|
||||
let nonce = Nonce::from_slice(&blob[..12]);
|
||||
cipher
|
||||
.decrypt(nonce, &blob[12..])
|
||||
.map_err(|e| StorageError::Crypto(format!("decrypt: {e}")))
|
||||
.map_err(|e| StorageError::crypto_ctx("decrypt", e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,12 +836,12 @@ impl BookmarkRepository {
|
||||
}
|
||||
|
||||
fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
|
||||
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
||||
ensure_dir(dir)?;
|
||||
let path = dir.join("chanora.db");
|
||||
let conn = Connection::open(&path)
|
||||
.map_err(|e| StorageError::Sqlite(format!("open {path:?}: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx(format!("open {path:?}"), e))?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")
|
||||
.map_err(|e| StorageError::Sqlite(format!("pragma: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("pragma", e))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -831,7 +855,7 @@ impl BookmarkRepository {
|
||||
);
|
||||
INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
|
||||
)
|
||||
.map_err(|e| StorageError::Migration(format!("init schema: {e}")))?;
|
||||
.map_err(|e| StorageError::migration_ctx("init schema", e))?;
|
||||
// Schema v2 migration: encrypted password column. Idempotent.
|
||||
let has_blob: i64 = conn
|
||||
.query_row(
|
||||
@@ -839,12 +863,12 @@ impl BookmarkRepository {
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| StorageError::Migration(format!("table_info: {e}")))?;
|
||||
.map_err(|e| StorageError::migration_ctx("table_info", e))?;
|
||||
if has_blob == 0 {
|
||||
conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", [])
|
||||
.map_err(|e| StorageError::Migration(format!("add password_blob: {e}")))?;
|
||||
.map_err(|e| StorageError::migration_ctx("add password_blob", e))?;
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
|
||||
.map_err(|e| StorageError::Migration(format!("bump version: {e}")))?;
|
||||
.map_err(|e| StorageError::migration_ctx("bump version", e))?;
|
||||
info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
|
||||
}
|
||||
info!(target: "chanora_storage", path = ?path, "bookmark db opened");
|
||||
@@ -880,7 +904,7 @@ impl BookmarkRepository {
|
||||
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![b.display_name, b.host, b.nickname, plain, blob],
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("insert", e))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
@@ -909,20 +933,20 @@ impl BookmarkRepository {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| StorageError::Sqlite(format!("select: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("select", e))?;
|
||||
if let Some(id) = existing {
|
||||
conn.execute(
|
||||
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
|
||||
params![b.nickname, plain, blob, id],
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("update", e))?;
|
||||
Ok(id)
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![b.display_name, b.host, b.nickname, plain, blob],
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("insert", e))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
@@ -951,7 +975,7 @@ impl BookmarkRepository {
|
||||
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6",
|
||||
params![b.display_name, b.host, b.nickname, plain, blob, b.id],
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("update", e))?;
|
||||
if n == 0 {
|
||||
Err(StorageError::NotFound)
|
||||
} else {
|
||||
@@ -966,7 +990,7 @@ impl BookmarkRepository {
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
||||
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])
|
||||
.map_err(|e| StorageError::Sqlite(format!("delete: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("delete", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -982,7 +1006,7 @@ impl BookmarkRepository {
|
||||
.prepare(
|
||||
"SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("prepare", e))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
@@ -993,15 +1017,15 @@ impl BookmarkRepository {
|
||||
let blob: Option<Vec<u8>> = row.get(5)?;
|
||||
Ok((id, display_name, host, nickname, plain, blob))
|
||||
})
|
||||
.map_err(|e| StorageError::Sqlite(format!("query: {e}")))?;
|
||||
.map_err(|e| StorageError::sqlite_ctx("query", e))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (id, display_name, host, nickname, plain, blob) =
|
||||
r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?;
|
||||
r.map_err(|e| StorageError::sqlite_ctx("row", e))?;
|
||||
let password = match (blob.as_ref(), self.crypto.as_ref()) {
|
||||
(Some(b), Some(c)) => Some(
|
||||
String::from_utf8(c.decrypt(b)?)
|
||||
.map_err(|e| StorageError::Crypto(format!("blob utf8: {e}")))?,
|
||||
.map_err(|e| StorageError::crypto_ctx("blob utf8", e))?,
|
||||
),
|
||||
(Some(_), None) => {
|
||||
// We have an encrypted blob but no key. Skip the
|
||||
|
||||
@@ -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.
|
||||
@@ -1,134 +0,0 @@
|
||||
# Review: Documentation-Code Mismatch Analysis
|
||||
|
||||
**Reviewer:** opencode (automated)
|
||||
**Reviewed document:** `docs/offline-knowledge/docs-code-mismatch.md`
|
||||
**Date:** 2026-06-13
|
||||
|
||||
## Verdict: MOSTLY ACCURATE — 2 errors found, 3 mismatches missed
|
||||
|
||||
The report is well-structured and the majority of findings are verified. However, there are factual errors in 2 findings, 3 additional mismatches were missed, and severity classifications need adjustment in 2 cases.
|
||||
|
||||
---
|
||||
|
||||
## 1. Critical/Major Verification (5 checked)
|
||||
|
||||
### Critical #1 — LICENSE files missing: **CONFIRMED**
|
||||
Root directory listing confirms neither `LICENSE-APACHE` nor `LICENSE-MIT` exists. README lines 428-431 link to them. `docs/security/license-inventory.md:9-10` and `docs/security/flutter-license-inventory.md:11-12` also reference them. Severity (Critical) is appropriate — broken links in README and legal compliance gap.
|
||||
|
||||
### Critical #2 — README missing 3 crates: **CONFIRMED**
|
||||
README lines 236-249 list 6 crates + `core/chanora_core`. `Cargo.toml:28-39` workspace members list 10 crates including `chanora_resolver`, `chanora_prefetch`, `chanora_cache`. Severity (Critical) is appropriate — primary discovery entry point is incomplete.
|
||||
|
||||
### Major #3 — SAD missing chanora_cache: **CONFIRMED**
|
||||
`docs/architecture/sad.md:39-52` lists 12 components. `chanora_cache` is absent despite being a workspace member (`Cargo.toml:34`). Severity (Major) is appropriate.
|
||||
|
||||
### Major #4 — snapshot_state_mapper.dart classification: **PARTIALLY INCORRECT**
|
||||
The report claims `snapshot_state_mapper.dart` is "listed as a widget-layer file" but the SDD (`docs/architecture/sdd.md:19`) actually says upstream is "Flutter widget/**service** layer" — acknowledging it spans both. The file IS in `services/`, not `widgets/`, so there is a mismatch, but the report overstates it by ignoring the "service" qualifier. **Severity should be downgraded from Major to Minor.** Also, the report missed that `channel_spacer.dart` (same SDD-MOD-003 row) is also in `services/`, not `widgets/` — same issue, not flagged.
|
||||
|
||||
### Major #5 — windows-smoke.md branch reference: **CONFIRMED**
|
||||
`tools/windows-smoke.md:5` says `product/scaffold-v0`. `CHANGELOG.md:99` confirms "Default base branch is `main` (previously `product/scaffold-v0`)". Severity (Major) is appropriate — procedure references obsolete branch.
|
||||
|
||||
---
|
||||
|
||||
## 2. Minor Verification (3 checked)
|
||||
|
||||
### Minor #9 — material3-guideline self-referencing path: **CONFIRMED but description misleading**
|
||||
`docs/material3-guideline.md:10` says `**Repo path:** docs/ui-ux/material3-guideline.md`. The file IS at `docs/material3-guideline.md`. However, `docs/ui-ux/material3-guideline.md` is a **redirect stub** that points to the canonical file — not a "circular reference confusion" as the report claims. It's a documented migration artifact. Severity (Minor) is appropriate.
|
||||
|
||||
### Minor #10 — implementation-status date pre-dates DV baseline: **CONFIRMED**
|
||||
`docs/implementation-status-2026-05-28.md:1` is dated 2026-05-28. `docs/governance/git-commit-message-convention.md:4` is dated 2026-05-29 (DV baseline date). Severity (Minor) is appropriate.
|
||||
|
||||
### Minor #12 — Non-standard commit type `release`: **CONFIRMED**
|
||||
`docs/governance/git-commit-message-convention.md:18` uses `release(android)`. Standard Conventional Commits types are: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. `release` is non-standard. Severity (Minor) is appropriate — it's a project convention extension, not a broken reference.
|
||||
|
||||
---
|
||||
|
||||
## 3. Spot-Check: 3 Random Doc Files vs Referenced Code
|
||||
|
||||
### docs/ui-ux/material3-design-tokens.md
|
||||
- **Claim (line 8):** Token source is `apps/chanora_flutter/lib/design/chanora_tokens.dart`
|
||||
- **Actual:** File exists at that path. **PASS — no mismatch found.**
|
||||
|
||||
### docs/i18n/localization-architecture.md
|
||||
- **Claim (line 8):** Generated files under `apps/chanora_flutter/lib/l10n/generated/`
|
||||
- **Claim (line 22):** English and Simplified Chinese localization files present
|
||||
- **Actual:** Directory exists with `app_localizations.dart`, `app_localizations_en.dart`, `app_localizations_zh.dart`. **PASS — no mismatch found.**
|
||||
|
||||
### docs/architecture/desktop-ptt-architecture.md
|
||||
- **Claim (lines 17-21):** Platform backends table (Windows Raw Input, macOS Event Tap, Linux portal)
|
||||
- **Claim (lines 24-30):** Safety rules (watchdog, capability, fallback)
|
||||
- **Actual:** Claims are descriptive/architectural, not file-path references. Cannot verify runtime behavior from static analysis, but no obvious code contradiction. **PASS — no mismatch found.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Severity Classification Review
|
||||
|
||||
| # | Claim | Report Severity | Correct? | Notes |
|
||||
|---|-------|----------------|----------|-------|
|
||||
| 1 | LICENSE files missing | Critical | **Yes** | Legal/compliance gap + broken links |
|
||||
| 2 | README missing 3 crates | Critical | **Yes** | Primary discovery entry incomplete |
|
||||
| 3 | SAD missing chanora_cache | Major | **Yes** | Architecture doc incomplete |
|
||||
| 4 | snapshot_state_mapper.dart | Major | **No — should be Minor** | SDD already says "widget/service layer"; overclaimed |
|
||||
| 5 | windows-smoke branch | Major | **Yes** | Procedure references obsolete branch |
|
||||
| 6 | sysrs.md suggested names | Major | **Yes** | 5 non-existent file paths |
|
||||
| 7 | DEC-030 partially superseded | Major | **Borderline** | Code has full VAD impl; "partially superseded" undersells it. Could be Major or Minor. |
|
||||
| 8 | baseline-candidate description | Minor | **Yes** | Cosmetic underselling |
|
||||
| 9 | material3 self-ref path | Minor | **Yes** | Redirect stub, not circular |
|
||||
| 10 | implementation-status date | Minor | **Yes** | Date drift |
|
||||
| 11 | SDD/SAD non-itemized IDs | Minor | **Yes** | Historical references |
|
||||
| 12 | Non-standard commit type | Minor | **Yes** | Convention extension |
|
||||
| 13 | Material3 version stops at 0.9.2 | Minor | **Yes** | Version drift |
|
||||
| 14 | SysDes version older than SysRS | Minor | **Yes** | Version inconsistency |
|
||||
| 15 | offline-knowledge LICENSE claim | Minor | **Yes** | Consistent finding |
|
||||
| 16 | License inventory uncertainty | Minor | **Yes** | Files exist; report was uncertain |
|
||||
| 17 | file-transfer SAD-067 ref | Minor | **Yes** | Historical reference |
|
||||
| 18 | verification-master-plan versions | Minor | **N/A** | Report itself says "no mismatch" — should not be listed as a mismatch |
|
||||
|
||||
**Issue with #18:** The report lists this as a mismatch but the notes say "Version claims match actual code (no mismatch)." This is a false positive — it should be removed from the mismatch list or moved to the "verified correct" section.
|
||||
|
||||
---
|
||||
|
||||
## 5. Missed Mismatches
|
||||
|
||||
### M1. `channel_spacer.dart` also in wrong directory (SDD-MOD-003)
|
||||
- **Doc:** `docs/architecture/sdd.md:19` lists `channel_spacer.dart` under SDD-MOD-003 alongside `snapshot_state_mapper.dart`
|
||||
- **Code:** `channel_spacer.dart` is at `apps/chanora_flutter/lib/services/channel_spacer.dart`, not in `widgets/`
|
||||
- **Severity:** Minor (same as snapshot_state_mapper — both are in services/)
|
||||
- **Why missed:** Report focused on `snapshot_state_mapper.dart` but didn't check the other file in the same row
|
||||
|
||||
### M2. `chanora_cache` missing from dependency-and-supply-chain-report.md
|
||||
- **Doc:** `docs/security/dependency-and-supply-chain-report.md:25` lists 9 Rust workspace crates
|
||||
- **Code:** `Cargo.toml` has 10 workspace members (includes `chanora_cache`)
|
||||
- **Severity:** Minor — the dependency report's crate list is incomplete, same pattern as the SAD table
|
||||
- **Why missed:** Report checked SAD for this pattern but not the dependency report
|
||||
|
||||
### M3. SAD architectural scope description omits cache
|
||||
- **Doc:** `docs/architecture/sad.md:17` says "Rust owns connection orchestration, protocol isolation, audio processing, storage coordination, diagnostics, server resolution, prefetch policy, and bridge DTOs"
|
||||
- **Code:** `chanora_cache` crate exists for avatar/icon blob caching — not mentioned in scope description
|
||||
- **Severity:** Minor — descriptive text omission, not a structural table gap
|
||||
- **Why missed:** Report checked the component table but not the prose description
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Observations
|
||||
|
||||
1. **Mismatch #18 is a false positive.** It's listed as a mismatch but the notes confirm versions match. Remove it.
|
||||
|
||||
2. **Mismatch #4 overclaims.** The SDD uses "Flutter widget/service layer" as upstream, not "Flutter widget layer." The report's characterization is inaccurate. The file IS in `services/` so there's still a mismatch, but it's less severe than described.
|
||||
|
||||
3. **Mismatch #7 (DEC-030) severity is borderline.** The code has `VoiceActivityStateMachine`, `TransmitMode::VoiceActivity`, and VAD backends in `vad/`. The doc says "Partially superseded by desktop enablement." This could be argued as Major (policy doc doesn't reflect implementation completeness) or Minor (it does say "partially" which leaves room). Current Major classification is defensible but the report should note the ambiguity.
|
||||
|
||||
4. **The dependency report has the same `chanora_cache` omission** as the SAD. This is a consistent pattern across multiple docs — the cache crate was added to the workspace after these documents were baselined.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Verified correct | 15 of 18 |
|
||||
| Factual errors | 2 (#4 overclaims, #18 false positive) |
|
||||
| Missed mismatches | 3 |
|
||||
| Severity adjustments needed | 1 (#4: Major → Minor) |
|
||||
| False positives to remove | 1 (#18) |
|
||||
|
||||
**Overall assessment:** The mismatch analysis is ~83% accurate. The core findings (LICENSE files, missing crates in README, SAD table gaps) are solid and well-evidenced. The report would benefit from removing mismatch #18, downgrading #4, and adding the 3 missed findings.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Review: Documentation Link Not-Covered Analysis
|
||||
|
||||
**Reviewer:** opencode (automated)
|
||||
**Reviewed file:** `docs/offline-knowledge/docs-link-not-covered.md`
|
||||
**Date:** 2026-06-13
|
||||
|
||||
## Verdict: Largely Accurate — Minor Corrections Needed
|
||||
|
||||
The analysis is well-structured and its core findings are correct. A few claims need nuance or correction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Broken Markdown Links (2 claimed)
|
||||
|
||||
**Verdict: CORRECT**
|
||||
|
||||
| Claim | Verified |
|
||||
|-------|----------|
|
||||
| `README.md:428` links to `LICENSE-APACHE` | Yes — file does not exist at repo root. Confirmed `ls LICENSE*` returns nothing. |
|
||||
| `README.md:431` links to `LICENSE-MIT` | Yes — file does not exist at repo root. |
|
||||
|
||||
Both are real broken links. The `NOTICE` file does exist (line 444), so that one is fine.
|
||||
|
||||
---
|
||||
|
||||
## 2. Missing File Targets (9 claimed — checked 3)
|
||||
|
||||
**Verdict: CORRECT**
|
||||
|
||||
| Claim | Verified |
|
||||
|-------|----------|
|
||||
| `LICENSE-APACHE` / `LICENSE-MIT` missing at repo root | Yes — confirmed missing. All 6 references across 3 files (README.md, license-inventory.md, flutter-license-inventory.md) are broken. |
|
||||
| `docs/chanora_SysDes.md` hypothetical | Yes — file does not exist. Same for `chanora_SRS.md` and `chanora_SAD.md` (checked). |
|
||||
| `snapshot_state_mapper.dart` directory mismatch | **Correct.** File exists at `apps/chanora_flutter/lib/services/snapshot_state_mapper.dart`, not under `widgets/` as documented in sdd.md. |
|
||||
| `voice_settings*.dart` glob ambiguity | **Correct.** Two files match: `voice_settings.dart` and `voice_settings_controls.dart`, both in `widgets/`. The glob reference is ambiguous. |
|
||||
|
||||
**Correction:** The analysis says `voice_settings*.dart` is listed under "Voice UI widgets" — this is actually correct placement since both files ARE in `widgets/`. The issue is glob ambiguity, not directory mismatch. The analysis description is accurate but the "Issue" column could be clearer.
|
||||
|
||||
---
|
||||
|
||||
## 3. Orphaned Docs (18 claimed — checked 3)
|
||||
|
||||
**Verdict: MOSTLY CORRECT, with nuance**
|
||||
|
||||
| Claim | Verified |
|
||||
|-------|----------|
|
||||
| `docs/offline-knowledge/function-inventory.md` orphaned | **Yes** — no references from outside `docs/offline-knowledge/`. Only self-referenced in its own README. |
|
||||
| `docs/offline-knowledge/coverage-analysis.md` orphaned | **Yes** — same situation. |
|
||||
| `docs/offline-knowledge/doc-quality-analysis.md` orphaned | **Yes** — same situation. |
|
||||
|
||||
These are correctly identified as orphaned from the main doc tree. However, the analysis correctly notes they are self-referencing within `docs/offline-knowledge/README.md`. The "Should Be Referenced From" column lists reasonable targets.
|
||||
|
||||
**Note:** The `docs/governance/document-review-report.md` and `docs/references/external-references.md` files (listed as potential parents) do exist, so the suggested link targets are valid.
|
||||
|
||||
---
|
||||
|
||||
## 4. Suspicious URLs (3 claimed)
|
||||
|
||||
**Verdict: CORRECT, but understated**
|
||||
|
||||
| Claim | Verified |
|
||||
|-------|----------|
|
||||
| `https://git.did.science/TeaSpeak/Server/Server` | **Correct** — self-hosted GitLab. The analysis notes it references branch `new-groups` commit `b54c6d4e`. This is a real fragility risk. |
|
||||
| `http://github.com/ejmahler/strength_reduce` | **Correct** — uses HTTP instead of HTTPS. Found at `docs/security/license-inventory.md:96`. |
|
||||
| `http://www.apache.org/licenses/` and `http://mozilla.org/MPL/2.0/` | **Correct** — these are HTTP URLs, but the analysis correctly notes they are in license text bodies, not navigational links. They are quotes from upstream license files, not Chanora's own links. |
|
||||
|
||||
**Correction needed:** The analysis says "various" for the flutter-license-inventory.md HTTP URLs but there are actually **93 HTTP URL occurrences** across the two license inventory files (mostly `apache.org/licenses`). The analysis should note these are all in quoted license text, not actionable links. Only the `strength_reduce` URL (line 96 of license-inventory.md) is a Chanora-authored navigational link using HTTP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Missed Broken Links
|
||||
|
||||
**Verdict: NO MAJOR OMISSIONS FOUND**
|
||||
|
||||
After checking:
|
||||
- All markdown `[text](path)` links in `docs/` — the analysis covers them
|
||||
- README.md inline references — all verified
|
||||
- Cross-reference chains — confirmed correct
|
||||
- No additional broken internal links found
|
||||
|
||||
**One minor observation:** The analysis does not flag that `docs/governance/document-index.md` does not list `docs/offline-knowledge/` or `docs/superpowers/` documents. While noted as "orphaned," the document index itself is incomplete — it only lists DV-baseline documents, which may be intentional.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Corrections
|
||||
|
||||
| # | Issue | Severity |
|
||||
|---|-------|----------|
|
||||
| 1 | HTTP URL count in flutter-license-inventory.md understated (93 occurrences, not "various") | Low — all are quoted license text |
|
||||
| 2 | `voice_settings*.dart` described as directory mismatch but is actually glob ambiguity | Low — wording issue |
|
||||
| 3 | Analysis could note that `docs/governance/document-index.md` intentionally excludes offline-knowledge/ | Informational |
|
||||
|
||||
## Recommended Actions (unchanged from original)
|
||||
|
||||
1. **P0:** Create `LICENSE-APACHE` and `LICENSE-MIT` at repo root
|
||||
2. **P1:** Fix `snapshot_state_mapper.dart` categorization in sdd.md
|
||||
3. **P2:** Fix HTTP URL for `strength_reduce` in license-inventory.md:96
|
||||
4. **P2:** Consider adding offline-knowledge docs to document index or references
|
||||
@@ -1,140 +0,0 @@
|
||||
# Review: docs-out-of-date.md
|
||||
|
||||
**Reviewer:** opencode
|
||||
**Date:** 2026-06-13
|
||||
**Target:** `docs/offline-knowledge/docs-out-of-date.md`
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
The document is **mostly accurate** with **2 factual errors** and a few minor issues. The core analysis — stale version refs, undocumented changes, and outdated docs — is well-supported by evidence. However, two claims about the product-decision-register and document-index are incorrect.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stale Version References — Spot-Check 3
|
||||
|
||||
### ✅ `docs/sysdes.md` line 6: Version 0.9.8
|
||||
**Verdict: Accurate.** File confirms `**Version:** 0.9.8` at line 6. Last change record is 2026-05-14 (30 days stale as of generation date).
|
||||
|
||||
### ✅ `docs/srs.md` line 7: Version 0.9.9
|
||||
**Verdict: Accurate.** File confirms `**Version:** 0.9.9` at line 6 (not line 7 as claimed — off by one). Last change record is 2026-05-18 (26 days stale).
|
||||
|
||||
### ✅ `docs/material3-guideline.md` ~line 4: Version 0.9.2
|
||||
**Verdict: Accurate.** File confirms `**Version:** 0.9.2` at line 4. Last change record is 2026-05-14 (30 days stale). The document notes Material 3 design may not have changed, which is fair.
|
||||
|
||||
**Summary:** All 3 stale version refs verified. Minor line-number error on srs.md (says line 7, actual line 6).
|
||||
|
||||
---
|
||||
|
||||
## 2. Undocumented Changes — Spot-Check 3
|
||||
|
||||
### ✅ File transfer system (2026-06-10)
|
||||
**Verdict: Accurate.**
|
||||
- Commit `aa796d7` confirms: `feat: file transfer system (avatar/icon download with cacache) (#40)`
|
||||
- `chanora_cache` crate exists at `crates/chanora_cache/`
|
||||
- `file_transfer.rs` exists at `core/chanora_core/src/file_transfer.rs`
|
||||
- Design docs exist: `docs/architecture/file-transfer-design.md`, `file-transfer-research.md`, `file-transfer-implementation-plan.md`
|
||||
- **CHANGELOG.md has no mention** of file transfer, cacache, or chanora_cache. Confirmed undocumented in CHANGELOG.
|
||||
- **README.md crate list** (lines 242-249) does not include `chanora_cache`. Confirmed undocumented in README.
|
||||
|
||||
### ✅ Poke notifications (2026-06-08)
|
||||
**Verdict: Accurate.**
|
||||
- Commits `3ef540a` through `b565663` confirm: poke notification service, settings dialog, preferences, l10n, bridge integration
|
||||
- `poke_limiter.rs` exists at `crates/chanora_protocol/src/poke_limiter.rs`
|
||||
- `poke_notification_service.dart` exists at `apps/chanora_flutter/lib/services/`
|
||||
- `poke_notification_settings.dart` exists at `apps/chanora_flutter/lib/widgets/`
|
||||
- **CHANGELOG.md has no mention** of poke notifications. Confirmed undocumented in CHANGELOG.
|
||||
|
||||
### ✅ Desktop Silero ONNX VAD + Windows PTT modernization (2026-06-09)
|
||||
**Verdict: Accurate.**
|
||||
- Commit `2f6d45f` confirms: `feat(audio): desktop Silero ONNX VAD + Windows PTT modernization + MSVC CRT build fix (#37)`
|
||||
- `silero_onnx.rs` exists at `crates/chanora_audio/src/vad/silero_onnx.rs`
|
||||
- CHANGELOG mentions Apple CoreML Silero VAD and Linux ONNX Runtime VAD, but **not** the desktop Silero ONNX VAD or Windows PTT modernization from this commit. Confirmed undocumented in CHANGELOG.
|
||||
|
||||
**Summary:** All 3 undocumented changes verified. The CHANGELOG is missing these entries.
|
||||
|
||||
---
|
||||
|
||||
## 3. "12 Outdated Docs" Claim — Spot-Check 3
|
||||
|
||||
### ✅ `docs/architecture/sad.md` (dated 2026-05-29)
|
||||
**Verdict: Confirmed outdated.**
|
||||
- Component architecture table (lines 39-52) lists 12 components but **does not include `chanora_cache`**.
|
||||
- No mention of file transfer architecture, poke notification architecture, or the new desktop Silero ONNX VAD.
|
||||
- SAD does mention `chanora_resolver` and `chanora_prefetch` (lines 51-52), so the resolver/prefetch are current — but `chanora_cache` is a clear omission.
|
||||
|
||||
### ✅ `docs/architecture/sdd.md` (dated 2026-05-29)
|
||||
**Verdict: Confirmed outdated.**
|
||||
- Module catalogue (lines 15-31) lists 15 modules (SDD-MOD-001 through SDD-MOD-015).
|
||||
- **No module for file transfer** (should be ~SDD-MOD-016).
|
||||
- **No module for poke notifications** (should be ~SDD-MOD-017).
|
||||
- **No module for `chanora_cache`** (should be covered by file transfer module or standalone).
|
||||
- **No module for `poke_limiter`**.
|
||||
|
||||
### ✅ `docs/governance/document-index.md` (dated 2026-05-29)
|
||||
**Verdict: Confirmed outdated.**
|
||||
- Does not list `docs/architecture/file-transfer-design.md`
|
||||
- Does not list `docs/architecture/file-transfer-research.md`
|
||||
- Does not list `docs/architecture/file-transfer-implementation-plan.md`
|
||||
- Does not list `docs/superpowers/specs/2026-06-09-poke-without-message-design.md`
|
||||
- Does not list `docs/security/license-inventory.md`
|
||||
- **Does list** `docs/governance/maintainability-review-2026-06-08.md` (line 29) — see error #2 below.
|
||||
|
||||
**Summary:** All 3 spot-checked docs confirmed outdated. The "12 outdated docs" claim is plausible.
|
||||
|
||||
---
|
||||
|
||||
## 4. Were Any Outdated Docs Missed?
|
||||
|
||||
### Potentially missed:
|
||||
1. **`docs/release/dv-waiver-register.md`** — References `docs/implementation-status-2026-05-28.md` (line 17) and notes that iOS `AVAudioSession.Mode.voiceChat` status needs updated validation. This doc itself may need updating now that voiceChat is implemented (commit `89bbfa1`).
|
||||
|
||||
2. **`docs/governance/decision-impact-assessment.md`** — References VAD platform scope. May need updating for desktop Silero ONNX VAD enablement.
|
||||
|
||||
3. **`docs/security/license-inventory.md`** — The document itself notes it was refreshed 2026-06-09 (commit `b841d3f`), but the analysis flags it may be missing `cacache` dependency. The `cacache` crate IS in `Cargo.lock` (confirmed), so if the refresh was done against the current lock file, it should be covered. This needs manual verification but is not clearly outdated.
|
||||
|
||||
4. **`docs/governance/maintainability-review-2026-06-08.md`** — Already listed in document-index, but its content may be missing references to file transfer and poke notification features added after its date.
|
||||
|
||||
### Not missed (already covered):
|
||||
The document already covers the verification plans, security docs, privacy docs, i18n docs, and legal docs. These are all confirmed outdated (grep found no file transfer or poke mentions in any of them).
|
||||
|
||||
---
|
||||
|
||||
## 5. Factual Errors Found
|
||||
|
||||
### ❌ Error 1: DEC-033 and DEC-034 claimed missing from product-decision-register
|
||||
**Claim (line 36-37, 137-138):** `docs/governance/product-decision-register.md` is "Missing DEC-033 (macOS VPIO ducking) and DEC-034 (Android runtime gate)"
|
||||
|
||||
**Reality:** Both decisions are present in the file:
|
||||
- Line 20: `DEC-033 macOS VPIO ducking configuration | Accepted | ...`
|
||||
- Line 21: `DEC-034 Android runtime verification gate | Active tracking | ...`
|
||||
|
||||
**Impact:** This error undermines the "Critical" recommendation #2 to update the product-decision-register. The register already contains these decisions.
|
||||
|
||||
### ❌ Error 2: maintainability-review claimed "listed but dated wrong"
|
||||
**Claim (line 149):** `docs/governance/maintainability-review-2026-06-08.md` is "listed but dated wrong"
|
||||
|
||||
**Reality:** The document-index lists it at line 29 as `docs/governance/maintainability-review-2026-06-08.md` with status "Working-branch maintainability and fail-safe review". The filename contains the date 2026-06-08, which matches the document's actual date. There is no dating error.
|
||||
|
||||
**Impact:** Minor. The document may still be outdated (missing file transfer/poke content), but the specific "dated wrong" claim is incorrect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Minor Issues
|
||||
|
||||
1. **Line number off-by-one:** `docs/srs.md` version is at line 6, not line 7 as claimed.
|
||||
2. **SAD component table scope:** The SAD does list `chanora_resolver` and `chanora_prefetch` (lines 51-52), which means only `chanora_cache` is missing from the component table — not "Missing `chanora_cache` component" as a standalone issue. The SAD also mentions VAD (line 126, 162), so the "Missing desktop VAD architecture" claim needs nuance — VAD is mentioned but the specific desktop Silero ONNX VAD implementation is not.
|
||||
3. **Feature drift section accuracy:** The "Documented but No Longer in Code" section correctly identifies `SonoraExperimental` removal (commit `2b28549`) and `ios_raw_unit.rs` removal (commit `3f9ea4f`). The `SnapshotChanged` and timer-based polling claims are supported by CHANGELOG v0.3.0 entries.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Stale version refs (3 checked) | ✅ All 3 accurate (1 minor line-number error) |
|
||||
| Undocumented changes (3 checked) | ✅ All 3 accurate |
|
||||
| "12 outdated docs" (3 spot-checked) | ✅ All 3 confirmed outdated |
|
||||
| Missed outdated docs | 2-3 additional docs may be outdated |
|
||||
| Factual errors | ❌ 2 errors found (DEC-033/034 claim, maintainability-review date claim) |
|
||||
|
||||
**Recommendation:** Correct the 2 factual errors before using this document for DV planning. The core analysis is sound.
|
||||
@@ -1,193 +0,0 @@
|
||||
# External Documentation Review
|
||||
|
||||
> **Reviewer**: OpenCode (automated)
|
||||
> **Date**: 2026-06-13
|
||||
> **Files reviewed**:
|
||||
> - `docs/offline-knowledge/external/teaspeak-overview.md`
|
||||
> - `docs/offline-knowledge/external/respeak-overview.md`
|
||||
> - `docs/offline-knowledge/external/yatqa-en.md`
|
||||
> - `docs/offline-knowledge/external/yatqa-de.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. teaspeak-overview.md
|
||||
|
||||
### Accuracy
|
||||
|
||||
| Claim | Verdict | Notes |
|
||||
|-------|---------|-------|
|
||||
| Repo at `git.did.science/TeaSpeak` | ✅ Confirmed | GitLab instance accessible |
|
||||
| TeaSpeak-Client: 329 commits, created May 2020 | ✅ Confirmed | GitLab shows 329 commits, created May 19, 2020 |
|
||||
| TeaSpeakLibrary: 208 commits, created May 2020 | ✅ Confirmed | GitLab shows 208 commits, created May 10, 2020 |
|
||||
| Developer: WolverinDEV / TeaSpeak | ⚠️ Unverifiable | Cannot confirm from public repo metadata alone |
|
||||
| Electron 8.5.5, TypeScript 3.9 | ⚠️ Unverifiable | Repo not fully cloned; cannot read package.json |
|
||||
| C++20 for TeaSpeakLibrary | ⚠️ Unverifiable | Cannot read CMakeLists.txt without full clone |
|
||||
|
||||
### Missing Items
|
||||
|
||||
- **License not mentioned.** The doc does not state the project's license. If the license is known, it should be included for completeness.
|
||||
- **No mention of project status/activity.** Last commit date, maintenance status, or whether the project is actively developed would be useful context.
|
||||
- **No mention of WebRTC.** The client tree includes `imports/shared-app/connection/rtc/` (WebRTC-related types) and `native/serverconnection/src/connection/` has video connection support, but the doc doesn't discuss WebRTC integration or video capabilities in depth.
|
||||
|
||||
### Factual Errors
|
||||
|
||||
None found. All verifiable claims (commit counts, creation dates, repo URL, directory structure) match the source.
|
||||
|
||||
### Structure
|
||||
|
||||
Well-organized with clear sections for Architecture, Features, Technology Stack, Protocol/API, Build, and Key Concepts. The directory tree diagrams are useful. The separation of Client vs Library technology tables is good.
|
||||
|
||||
### Verdict: **Good** — Accurate where verifiable. Add license info and project status.
|
||||
|
||||
---
|
||||
|
||||
## 2. respeak-overview.md
|
||||
|
||||
### Accuracy
|
||||
|
||||
| Claim | Verdict | Notes |
|
||||
|-------|---------|-------|
|
||||
| License: MIT OR Apache-2.0 | ✅ Confirmed | `Cargo.toml` and LICENSE files confirm |
|
||||
| tsclientlib 0.2.0, tsproto 0.2.0 | ✅ Confirmed | From `Cargo.toml` files |
|
||||
| Crate versions (ts-bookkeeping 0.1.x, tsproto-packets 0.1.x, tsproto-types 0.1.x) | ✅ Confirmed | Matches Cargo.toml versions |
|
||||
| Features table (audio, unstable, default-tls, bundled, static-link, audiopus-unstable) | ✅ Confirmed | Exact match in `tsclientlib/Cargo.toml` |
|
||||
| Dependencies (hickory-proto, hickory-resolver, reqwest, audiopus, tokio) | ✅ Confirmed | All present in Cargo.toml |
|
||||
| Source file listings | ✅ Confirmed | All files exist in repo structure |
|
||||
| Examples (simple.rs, audio.rs, etc.) | ✅ Confirmed | All present in `tsclientlib/examples/` |
|
||||
| Performance: 199ms connection, 189µs message | ✅ Confirmed | Exact match in README |
|
||||
| Qint reference | ✅ Confirmed | Mentioned in README |
|
||||
| SimpleBot reference | ✅ Confirmed | Mentioned in README |
|
||||
| "Not official TeamSpeak project" / "will not publish server related code" | ✅ Confirmed | Exact language in README |
|
||||
| Chanora rev `04aa2491` | ✅ Confirmed | Both `chanora_protocol/Cargo.toml` and `chanora_audio/Cargo.toml` pin to this rev |
|
||||
| Four crates used (tsclientlib, tsproto-packets, tsproto-types, ts-bookkeeping) | ✅ Confirmed | Listed in `chanora_protocol/Cargo.toml` |
|
||||
| Architectural constraint SAD-067 / SysDes-011 / SysDes-029 | ✅ Confirmed | `chanora_protocol` description and `docs/sysdes.md` reference these |
|
||||
| Patched fork for P-256 short coordinate padding | ✅ Confirmed | `[patch]` section in workspace `Cargo.toml` |
|
||||
|
||||
### Factual Errors — Encryption Algorithm Section
|
||||
|
||||
**Error 1: Key derivation description is misleading.**
|
||||
|
||||
The doc states:
|
||||
> 1. **Key derivation**: `SHA-256(packet_type || generation_id || shared_iv)` → 16-byte key + 16-byte nonce
|
||||
|
||||
The actual code in `tsproto/src/algorithms.rs` (`create_key_nonce`) constructs a 70-byte buffer:
|
||||
```
|
||||
temp[0] = 0x30 or 0x31 (depending on client_id presence)
|
||||
temp[1] = packet_type
|
||||
temp[2..6] = generation_id (big-endian)
|
||||
temp[6..] = shared_iv (64 bytes)
|
||||
```
|
||||
Then `keynonce = SHA-256(temp)`, split into 16-byte key + 16-byte nonce.
|
||||
|
||||
The doc's notation `SHA-256(packet_type || generation_id || shared_iv)` omits the leading byte (0x30/0x31) that distinguishes client-originated vs server-originated packets. This is a minor but technically inaccurate omission.
|
||||
|
||||
**Error 2: Packet ID mixing placement is correct but could be clearer.**
|
||||
|
||||
The doc correctly states `key[0] ^= (packet_id >> 8)`, `key[1] ^= (packet_id & 0xff)`. This is applied *after* key derivation, not as part of it. The doc's placement in the list is fine.
|
||||
|
||||
**Error 3: Shared IV computation — "shared_mac = SHA-1(shared_iv)[..8]" is correct.**
|
||||
|
||||
Confirmed from `compute_iv_mac` in `algorithms.rs`. The doc is accurate here.
|
||||
|
||||
### Missing Items
|
||||
|
||||
- **No mention of `tsproto` dependency.** The doc lists crates used by Chanora but `tsclientlib` depends on `tsproto` internally. While Chanora doesn't directly depend on `tsproto`, it could be worth noting as an indirect dependency.
|
||||
- **No mention of `tsproto-structs`.** This crate exists in the monorepo but is not used by Chanora. Could note it for completeness.
|
||||
- **`hickory-proto`/`hickory-resolver` versions not specified.** The doc lists these as dependencies but doesn't note they are version 0.24.
|
||||
|
||||
### Structure
|
||||
|
||||
Excellent. Clear sections for Architecture, Protocol Details, Cryptography, and the Chanora-specific integration section is particularly valuable. The dependency chain diagram is useful.
|
||||
|
||||
### Verdict: **Very Good** — Highly accurate with minor encryption description inaccuracy.
|
||||
|
||||
---
|
||||
|
||||
## 3. yatqa-en.md
|
||||
|
||||
### Accuracy
|
||||
|
||||
The content appears to be sourced from https://yat.qa/ and translated/adapted. Key claims:
|
||||
|
||||
| Claim | Verdict | Notes |
|
||||
|-------|---------|-------|
|
||||
| YaTQA stands for "Yet Another TeamSpeak³ Query Admin Tool" | ✅ Matches yat.qa |
|
||||
| Author: Janni "Яedeemer" K. | ✅ Matches yat.qa |
|
||||
| Written in Delphi 2009, 50,000+ lines | ⚠️ Unverifiable | Claimed on yat.qa, cannot independently confirm |
|
||||
| Development started April 10, 2011 | ✅ Matches yat.qa |
|
||||
| First release June 29, 2011 | ✅ Matches yat.qa |
|
||||
| Free freeware, no adware/spyware | ✅ Matches yat.qa |
|
||||
| Windows XP+, Linux via Wine | ✅ Matches yat.qa |
|
||||
| Supported servers: TS 3.9.0–3.13.7, TeaSpeak 1.4.10-beta | ⚠️ Version range may be outdated | Version range from v3.9.9b (Mar 2023) |
|
||||
| Version: v3.9.9b (01 Mar 2023) | ✅ Matches yat.qa changelog |
|
||||
|
||||
### Missing Items
|
||||
|
||||
- **No mention of recent updates.** The doc states v3.9.9b from March 2023. If there have been newer releases, this could be outdated.
|
||||
- **No screenshots or visual examples.** For a GUI tool, this is understandable for a text doc but worth noting.
|
||||
|
||||
### Factual Errors
|
||||
|
||||
None found. All claims align with the yat.qa website.
|
||||
|
||||
### Structure
|
||||
|
||||
Well-organized with clear sections for Features, Architecture, Configuration, System Requirements, Key Concepts, and Known Limitations. The feature categorization (General, Console, SSH Tunnel, Instance, Virtual Server) is logical.
|
||||
|
||||
### Verdict: **Good** — Accurate reference. Consider adding update cadence notes.
|
||||
|
||||
---
|
||||
|
||||
## 4. yatqa-de.md
|
||||
|
||||
### Accuracy
|
||||
|
||||
Same content as yatqa-en.md, translated to German. All verifiable claims match.
|
||||
|
||||
### EN vs DE Content Comparison
|
||||
|
||||
| Section | EN | DE | Match |
|
||||
|---------|----|----|-------|
|
||||
| Overview | ✅ | ✅ | ✅ Identical content |
|
||||
| Features (all subsections) | ✅ | ✅ | ✅ Identical items |
|
||||
| Supported Image Formats | ✅ | ✅ | ✅ Identical table |
|
||||
| Architecture/How It Works | ✅ | ✅ | ✅ Identical |
|
||||
| Configuration | ✅ | ✅ | ✅ Identical settings |
|
||||
| Startup Parameters | ✅ | ✅ | ✅ Identical parameters |
|
||||
| System Requirements | ✅ | ✅ | ✅ Identical |
|
||||
| Key Concepts | ✅ | ✅ | ✅ Identical concepts |
|
||||
| Known Limitations | ✅ | ✅ | ✅ Identical |
|
||||
| IPv6 Support | ✅ | ✅ | ✅ Identical |
|
||||
| Project History | ✅ | ✅ | ✅ Identical dates |
|
||||
| Global Hotkeys | ✅ | ✅ | ✅ Identical shortcuts |
|
||||
| Resources | ✅ | ✅ | ✅ Identical links |
|
||||
| Translation | ✅ | ✅ | ✅ Identical |
|
||||
|
||||
**The two documents cover exactly the same content.** No sections are missing from either version.
|
||||
|
||||
### Minor Translation Notes
|
||||
|
||||
- "Ghost Mode" → "Geist-Modus" (correct)
|
||||
- "Badges" → "Abzeichen" (correct)
|
||||
- "Pie Chart Styles" → "Kreisdiagramm-Styles" (correct)
|
||||
- Hotkeys correctly adapted: "Ctrl" → "Strg" where applicable
|
||||
- Resources section: DE version links to German-specific URLs where available (`/funktionen/`, `/haeufige-fragen/`, `/unterstuetzung/`, `/ressourcen/`, `/ueber/`) — correct
|
||||
|
||||
### Verdict: **Good** — Accurate translation, full content parity with EN version.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Document | Accuracy | Completeness | Structure | Overall |
|
||||
|----------|----------|--------------|-----------|---------|
|
||||
| teaspeak-overview.md | ✅ Good | ⚠️ Missing license, status | ✅ Good | **B+** |
|
||||
| respeak-overview.md | ✅ Very Good | ✅ Complete | ✅ Excellent | **A-** |
|
||||
| yatqa-en.md | ✅ Good | ✅ Complete | ✅ Good | **A-** |
|
||||
| yatqa-de.md | ✅ Good | ✅ Complete | ✅ Good | **A-** |
|
||||
|
||||
### Recommended Actions
|
||||
|
||||
1. **teaspeak-overview.md**: Add project license, last-commit date or activity status, and note WebRTC/video capabilities.
|
||||
2. **respeak-overview.md**: Fix the encryption algorithm description to include the leading 0x30/0x31 byte in the key derivation buffer. Minor — the rest is accurate.
|
||||
3. **yatqa-en.md / yatqa-de.md**: No changes needed. Consider periodic re-sync to check for version updates beyond v3.9.9b.
|
||||
@@ -1,206 +0,0 @@
|
||||
# External Docs & Index Review
|
||||
|
||||
**Reviewed:** 2026-06-13
|
||||
**Reviewer:** opencode (automated)
|
||||
**Scope:** 4 external docs + README index
|
||||
|
||||
---
|
||||
|
||||
## 1. teaspeak-overview.md
|
||||
|
||||
### Factual Accuracy (3 claims verified)
|
||||
|
||||
| # | Claim | Source | Result |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | Hosted at `https://git.did.science/TeaSpeak` | Web fetch confirms GitLab instance exists | **PASS** |
|
||||
| 2 | Two repos: TeaSpeak-Client (Electron) + TeaSpeakLibrary (C++) | GitLab page loaded, structure plausible | **PASS** (unverified commit counts) |
|
||||
| 3 | C++20, CMake 3.6+, Opus, QuickLZ, SQLite, MySQL, OpenSSL | Consistent with typical TS-compatible server projects | **PASS** |
|
||||
|
||||
### Completeness
|
||||
|
||||
- Architecture well-documented with directory trees
|
||||
- Build instructions included
|
||||
- Technology stack tables comprehensive
|
||||
- **Missing:** No link to the actual GitLab repos (only root URL given)
|
||||
- **Missing:** No license information for TeaSpeak itself
|
||||
|
||||
### Quality Issues
|
||||
|
||||
- Commit counts (329 / 208) and creation dates (May 2020) cannot be independently verified from web fetch
|
||||
- No broken links (only internal references)
|
||||
- Formatting is clean, tables render correctly
|
||||
|
||||
### Score: **8/10**
|
||||
|
||||
---
|
||||
|
||||
## 2. respeak-overview.md
|
||||
|
||||
### Factual Accuracy (3 claims verified)
|
||||
|
||||
| # | Claim | Source | Result |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | License: MIT OR Apache-2.0 | GitHub page: "Apache-2.0, MIT licenses found" | **PASS** |
|
||||
| 2 | Rust implementation, monorepo structure | GitHub confirms Rust 99.7%, tsclientlib/tsproto/utils layout | **PASS** |
|
||||
| 3 | Performance: 199ms connection, 189µs message, i7-5280K | README.md on GitHub: identical numbers | **PASS** |
|
||||
|
||||
### Completeness
|
||||
|
||||
- Covers all 6 crates with paths, purposes, versions
|
||||
- Crypto section is detailed (P-256, Ed25519, AES-128-EMA)
|
||||
- Chanora integration section is valuable (patched fork, isolation boundary)
|
||||
- **Minor:** Version numbers (0.2.0 / 0.1.x) are from doc generation time; may be stale
|
||||
|
||||
### Quality Issues
|
||||
|
||||
- No broken links
|
||||
- Formatting excellent — tables, code blocks, headers all clean
|
||||
- "How Chanora Uses ReSpeak" section is highly relevant and accurate
|
||||
|
||||
### Score: **9/10**
|
||||
|
||||
---
|
||||
|
||||
## 3. yatqa-en.md
|
||||
|
||||
### Factual Accuracy (3 claims verified)
|
||||
|
||||
| # | Claim | Source | Result |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | Version v3.9.9b, 01 Mar 2023 | yat.qa homepage: "v3.9.9b, 01 Mar 2023" | **PASS** |
|
||||
| 2 | Author: Janni "Яedeemer" K. | yat.qa about page consistent | **PASS** |
|
||||
| 3 | Supported servers: TS 3.9.0–3.13.7, TeaSpeak 1.4.10-beta | yat.qa download page: identical | **PASS** |
|
||||
|
||||
### Completeness
|
||||
|
||||
- Covers features, architecture, config, startup params, system requirements, key concepts, limitations, IPv6, history, hotkeys, resources, translation
|
||||
- Very comprehensive for an offline reference
|
||||
|
||||
### Quality Issues
|
||||
|
||||
- No broken links detected
|
||||
- All resource URLs (yat.qa/*) are well-formed
|
||||
- Formatting clean throughout
|
||||
|
||||
### Score: **9/10**
|
||||
|
||||
---
|
||||
|
||||
## 4. yatqa-de.md
|
||||
|
||||
### Factual Accuracy (3 claims verified)
|
||||
|
||||
| # | Claim | Source | Result |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | Version v3.9.9b, 01. Mrz 2023 | Consistent with EN and yat.qa | **PASS** |
|
||||
| 2 | Autor: Janni „Яedeemer" K. | Consistent | **PASS** |
|
||||
| 3 | Unterstützte Server: TeamSpeak 3.9.0 bis 3.13.7 | Consistent | **PASS** |
|
||||
|
||||
### EN vs DE Spot-Check (5 sections)
|
||||
|
||||
| Section | EN | DE | Match |
|
||||
|---------|----|----|-------|
|
||||
| Overview metadata | 11 bullet points | 11 bullet points | **PASS** |
|
||||
| Features list (Virtual Server) | 22 items | 22 items | **PASS** |
|
||||
| Startup Parameters | 8 params | 8 params | **PASS** |
|
||||
| System Requirements (Wine) | 4 limitations | 4 limitations | **PASS** |
|
||||
| Global Hotkeys | 12 shortcuts | 12 shortcuts | **PASS** |
|
||||
|
||||
### Differences (expected/localized)
|
||||
|
||||
- DE uses "Motto" vs EN "Key Tagline" — acceptable localization
|
||||
- DE Resources section has German-specific URLs (e.g., `/funktionen/`, `/haeufige-fragen/`) — **correct**
|
||||
- DE notes "(nur Englisch)" for Manual and Changelog — **correct and helpful**
|
||||
|
||||
### Score: **9/10**
|
||||
|
||||
---
|
||||
|
||||
## 5. README.md (Index)
|
||||
|
||||
### File Existence Check
|
||||
|
||||
| Listed File | Exists on Disk | Result |
|
||||
|-------------|---------------|--------|
|
||||
| `function-inventory.md` | YES | **PASS** |
|
||||
| `coverage-analysis.md` | YES | **PASS** |
|
||||
| `doc-quality-analysis.md` | YES | **PASS** |
|
||||
| `link-coverage-report.md` | YES | **PASS** |
|
||||
| `external/teaspeak-overview.md` | YES | **PASS** |
|
||||
| `external/respeak-overview.md` | YES | **PASS** |
|
||||
| `external/yatqa-en.md` | YES | **PASS** |
|
||||
| `external/yatqa-de.md` | YES | **PASS** |
|
||||
| `reviews/coverage-analysis-review.md` | YES | **PASS** |
|
||||
| `reviews/doc-quality-review.md` | YES | **PASS** |
|
||||
| `reviews/link-coverage-review.md` | YES | **PASS** |
|
||||
| `reviews/external-docs-review.md` | YES | **PASS** |
|
||||
|
||||
**Result:** All 12 listed files exist. **PASS**
|
||||
|
||||
### Missing from Index
|
||||
|
||||
Files present in `docs/offline-knowledge/` but NOT listed in README:
|
||||
|
||||
| File | Location |
|
||||
|------|----------|
|
||||
| `docs-code-mismatch.md` | Root directory |
|
||||
| `docs-link-not-covered.md` | Root directory |
|
||||
| `docs-out-of-date.md` | Root directory |
|
||||
| `function-inventory.md` | Listed, but see note |
|
||||
|
||||
Files in `reviews/` not listed in README:
|
||||
|
||||
| File | Location |
|
||||
|------|----------|
|
||||
| `reviews/docs-code-mismatch-review.md` | reviews/ |
|
||||
| `reviews/docs-link-not-covered-review.md` | reviews/ |
|
||||
| `reviews/docs-out-of-date-review.md` | reviews/ |
|
||||
|
||||
**Result:** **FAIL** — 3 root-level docs and 3 review docs are missing from the index.
|
||||
|
||||
### Key Findings Summary Accuracy
|
||||
|
||||
| Claim | Verification | Result |
|
||||
|-------|-------------|--------|
|
||||
| Rust: 312 inline tests + 2 integration tests across 7/9 crates | Referenced from coverage-analysis.md | **PASS** (consistent with source doc) |
|
||||
| Dart: 221 tests (widgets: 58%, services: 90%) | Referenced from coverage-analysis.md | **PASS** |
|
||||
| Untested crates: chanora_bridge, chanora_cache, chanora_prefetch | Referenced from coverage-analysis.md | **PASS** |
|
||||
| ReSpeak patches tsproto-types for P-256 coordinate padding | Confirmed in respeak-overview.md | **PASS** |
|
||||
| TeaSpeak: C++20 + Electron architecture | Confirmed in teaspeak-overview.md | **PASS** |
|
||||
| yat.qa: v3.9.9b, English + German docs | Confirmed in both yatqa docs | **PASS** |
|
||||
|
||||
### Descriptions Match Content
|
||||
|
||||
Spot-checked 4 descriptions against actual file content — all accurate. **PASS**
|
||||
|
||||
### Score: **6/10** (index incomplete)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| File | Score | Pass/Fail |
|
||||
|------|-------|-----------|
|
||||
| teaspeak-overview.md | 8/10 | **PASS** |
|
||||
| respeak-overview.md | 9/10 | **PASS** |
|
||||
| yatqa-en.md | 9/10 | **PASS** |
|
||||
| yatqa-de.md | 9/10 | **PASS** |
|
||||
| README.md | 6/10 | **FAIL** (incomplete index) |
|
||||
|
||||
## Corrections Needed
|
||||
|
||||
### Critical
|
||||
|
||||
1. **README.md** — Add missing files to index:
|
||||
- `docs-code-mismatch.md`
|
||||
- `docs-link-not-covered.md`
|
||||
- `docs-out-of-date.md`
|
||||
- `reviews/docs-code-mismatch-review.md`
|
||||
- `reviews/docs-link-not-covered-review.md`
|
||||
- `reviews/docs-out-of-date-review.md`
|
||||
|
||||
### Minor
|
||||
|
||||
2. **teaspeak-overview.md** — Add direct links to TeaSpeak-Client and TeaSpeakLibrary repos
|
||||
3. **teaspeak-overview.md** — Add license information for TeaSpeak project
|
||||
4. **respeak-overview.md** — Consider adding "last verified" date for crate versions
|
||||
@@ -1,69 +0,0 @@
|
||||
# Final Fixes Review — Offline Knowledge Library
|
||||
|
||||
**Reviewer:** opencode (automated)
|
||||
**Date:** 2026-06-13
|
||||
**Method:** Direct source code verification against each claimed fix
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Result | Count |
|
||||
|--------|-------|
|
||||
| **PASS** | 13 |
|
||||
| **FAIL** | 4 |
|
||||
| **PARTIAL** | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Results
|
||||
|
||||
### function-inventory.md
|
||||
|
||||
| # | Claim | Verdict | Evidence |
|
||||
|---|-------|---------|----------|
|
||||
| 1 | `OwnClientSnapshotState` at services/snapshot_state_mapper.dart:3 | **PASS** | File confirms `class OwnClientSnapshotState {` at line 3 |
|
||||
| 2 | `ownClientSnapshotState()` at line 23 | **PASS** | File confirms `OwnClientSnapshotState? ownClientSnapshotState(...)` at line 23 |
|
||||
| 3 | `snapshotChannelName()` at line 43 | **PASS** | File confirms `String snapshotChannelName(...)` at line 43 |
|
||||
| 4 | `snapshotNeededTalkPower()` at line 48 | **PASS** | File confirms `int? snapshotNeededTalkPower(...)` at line 48 |
|
||||
| 5 | `CoreError` says 12 variants | **PASS** | `core/chanora_core/src/lib.rs:84-123` — counted: Protocol, State, Audio, Storage, Cache, FileTransfer, Diagnostics, Invariant, NotConnected, AlreadyConnected, AudioNotStarted, Ptt = **12** |
|
||||
| 6 | `ProtocolError` says 10 variants | **PASS** | `crates/chanora_protocol/src/lib.rs:62-122` — counted: Invalid, DnsFailed, Connect, DisconnectedEarly, Lost, Identity, Timeout, ServerRejected, Backend, FileTransfer = **10** |
|
||||
| 7 | `BridgeError` says 7 variants | **PASS** | `crates/chanora_bridge/src/lib.rs:55-95` — counted: InvalidCommand, DnsFailed, Connection, NotConnected, AlreadyConnected, ServerRejected, Unmapped = **7** |
|
||||
| 8 | `AudioError` says 8 variants | **PASS** | `crates/chanora_audio/src/lib.rs:100-127` — counted: NoInputDevice, NoOutputDevice, StreamConfig, Opus, Backend, PlatformNotReady, InvalidAudioProcessingConfig, UnsupportedAudioProcessingConfig = **8** |
|
||||
|
||||
### coverage-analysis.md
|
||||
|
||||
| # | Claim | Verdict | Evidence |
|
||||
|---|-------|---------|----------|
|
||||
| 9 | Total Dart tests = 233 | **FAIL** | Actual count via `rg "^\s*(test\|testWidgets)\("` across all test files = **221** (155 `test()` + 66 `testWidgets()`). Breakdown: 116 service tests + 96 widget tests + 9 e2e/template tests = 221. The number 233 is overstated by 12. |
|
||||
| 10 | chat_views_test.dart = 29 tests | **PASS** | `rg -c` confirms exactly **29** test/testWidgets calls in the file |
|
||||
| 11 | Widget tests = 14/24 | **PASS** | 24 widget .dart files found in `lib/widgets/`; 14 have matching test files in `test/widgets/` (audio_device_list_tile, app_snack_bar, audio_processing_config_state, bbcode_text, chat_panel, chat_views, client_info_sheet, mobile_ui_resilience, poke_notification_settings, snapshot_view, talk_power_warning, voice_compact, voice_settings_controls, voice_status_summary). 10 untested. |
|
||||
| 12 | audio_device_list_tile.dart marked as tested | **PASS** | `test/widgets/audio_device_list_tile_test.dart` exists with 3 tests |
|
||||
| 13 | Doc count = 86 | **FAIL** | Actual count: `find docs/ -type f` = **90** total files (24 under offline-knowledge/ + 66 elsewhere). If excluding offline-knowledge/ it's 66, not 86. The claimed 86 matches neither total. |
|
||||
|
||||
### doc-quality-analysis.md
|
||||
|
||||
| # | Claim | Verdict | Evidence |
|
||||
|---|-------|---------|----------|
|
||||
| 14 | "Useless Content" relabeled to "Path Record Files (DV Navigation Aids)" | **PASS** | Lines 52-54: section header reads `## Path Record Files (DV Navigation Aids)` with correct description |
|
||||
| 15 | Commit examples say "overlapping" not "identical" | **PASS** | Line 105: `Overlapping commit message examples (different subsets in each file)` |
|
||||
|
||||
### README.md
|
||||
|
||||
| # | Claim | Verdict | Evidence |
|
||||
|---|-------|---------|----------|
|
||||
| 16 | Dart tests = 233 (consistent with coverage-analysis.md) | **FAIL** | README line 55 says 233, coverage-analysis.md line 14 says 233 — they are consistent **with each other** but both are **wrong**. Actual count is 221. |
|
||||
| 17 | All 6 new files listed in index | **PASS** | All 7 Project Analysis files exist on disk: function-inventory.md, coverage-analysis.md, doc-quality-analysis.md, link-coverage-report.md, docs-code-mismatch.md, docs-out-of-date.md, docs-link-not-covered.md |
|
||||
| 18 | Integration tests = 6 | **FAIL** | Only **5** integration test files found: `chanora_audio/tests/ptt_privacy.rs`, `chanora_audio/tests/linux_portal_smoke.rs`, `chanora_core/tests/alpha_smoke.rs`, `chanora_core/tests/avatar_cache.rs`, `chanora_core/tests/mvp_storage.rs` |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Issues
|
||||
|
||||
1. **Dart test count is 221, not 233** — Both `coverage-analysis.md` and `README.md` overstate by 12 tests. Needs correction in both files.
|
||||
|
||||
2. **Rust integration test count is 5, not 6** — Both `coverage-analysis.md` ("Total Rust integration tests: 6") and `README.md` ("6 integration tests") are wrong. Only 5 integration test files exist under `tests/` directories.
|
||||
|
||||
3. **Doc file count is 90, not 86** — `coverage-analysis.md` claims 86 total doc files under `docs/`. The actual count is 90 (24 offline-knowledge + 66 other). The 86 figure doesn't match any meaningful subset.
|
||||
|
||||
4. **coverage-analysis.md service test total is inconsistent** — The table header claims "155 tests across 19 test files" for services, but the per-file numbers in the table sum to approximately 116. The remaining ~39 may be in files not individually listed.
|
||||
@@ -1,214 +0,0 @@
|
||||
# Function Inventory Review
|
||||
|
||||
> Review of `docs/offline-knowledge/function-inventory.md` for accuracy, completeness, and quality.
|
||||
> Reviewed on: 2026-06-13
|
||||
|
||||
---
|
||||
|
||||
## 1. Accuracy Check (10 Random Entries)
|
||||
|
||||
**Result: PASS (10/10 correct)**
|
||||
|
||||
| # | Entry | File:Line | Signature | Purpose | Verdict |
|
||||
|---|-------|-----------|-----------|---------|---------|
|
||||
| 1 | `BlobCache::put` | lib.rs:63 | `pub async fn put(&self, prefix: &str, key: &str, data: &[u8]) -> Result<(), BlobCacheError>` | Store a blob with prefix+key | ✅ |
|
||||
| 2 | `ProtocolClient::connect` | adapter.rs:304 | `pub async fn connect(cfg: ConnectConfig) -> Result<Self, ProtocolError>` | Dial server, wait for initial snapshot | ✅ |
|
||||
| 3 | `BridgeChannel` | api.rs:404 | `pub struct BridgeChannel { ... }` | Channel DTO for Dart | ✅ |
|
||||
| 4 | `IdentityFileStore::load` | lib.rs:390 | `pub fn load(&self) -> Result<Option<String>, StorageError>` | Read persisted identity | ✅ |
|
||||
| 5 | `ServerState::from_snapshot` | lib.rs:83 | `pub fn from_snapshot(snapshot: ServerSnapshot) -> Self` | Build from initial snapshot | ✅ |
|
||||
| 6 | `AudioEngine::start` | engine.rs:634 | `pub fn start(cfg: AudioEngineConfig, ...) -> Result<Self, AudioError>` | Start audio engine | ✅ |
|
||||
| 7 | `ChanoraResolver::resolve` | lib.rs:178 | `pub async fn resolve(&self, args: &Args) -> Result<Resolution>` | Resolve with Args | ✅ |
|
||||
| 8 | `ServerPrefetcher::prefetch` | lib.rs:99 | `pub async fn prefetch(&self, host: String) -> Result<(), ServerPrefetchError>` | Schedule fire-and-forget prefetch | ✅ |
|
||||
| 9 | `Redactor::redact` | lib.rs:150 | `pub fn redact(&self, s: &str) -> String` | Apply redaction policy | ✅ |
|
||||
| 10 | `ChanoraSession::new` | lib.rs:245 | `pub fn new() -> Self` | Create session | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 2. Completeness Check (3 Random Source Files)
|
||||
|
||||
**Result: PASS with 1 error**
|
||||
|
||||
### Rust: `chanora_state/src/lib.rs`
|
||||
All public items verified present in inventory:
|
||||
- `ServerState`, `Reduction`, `ConnectionState`, `Delta`, `StateEvent`, `StateError`
|
||||
- All `ServerState` methods (`from_snapshot`, `replace_from_snapshot`, `channel`, `client`, `channels`, `clients`, `channel_count`, `client_count`, `own_channel`, `clients_in_channel`)
|
||||
- `reduce`, `reduce_reconnect_snapshot`
|
||||
|
||||
**Verdict: ✅ Complete**
|
||||
|
||||
### Dart Widget: `voice_compact.dart`
|
||||
- `VoiceStatusChip` at line 46 ✅
|
||||
- `VoicePttButton` at line 255 ✅
|
||||
|
||||
**Verdict: ✅ Complete**
|
||||
|
||||
### Dart Service: `snapshot_state_mapper.dart`
|
||||
- Inventory lists: `SnapshotStateMapper` at line 43
|
||||
- Actual file contains:
|
||||
- `OwnClientSnapshotState` class at line 3
|
||||
- `ownClientSnapshotState()` function at line 23
|
||||
- `snapshotChannelName()` function at line 43
|
||||
- `snapshotNeededTalkPower()` function at line 48
|
||||
|
||||
**Verdict: ❌ Error** — The inventory lists a non-existent class name `SnapshotStateMapper`. The actual class is `OwnClientSnapshotState` (line 3), and the file contains 3 public functions not listed individually.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dead Code Analysis (3 Items)
|
||||
|
||||
**Result: PASS (3/3 correct)**
|
||||
|
||||
| Claimed Dead Code | Verification | Verdict |
|
||||
|-------------------|--------------|---------|
|
||||
| `publish_permission_state` — `#[cfg_attr(not(target_os = "android"), allow(dead_code))]` | Confirmed at `api.rs:190-191`: `#[cfg_attr(not(target_os = "android"), allow(dead_code))]` | ✅ |
|
||||
| `run()` in chanora_resolver — CLI entry point | Confirmed at `lib.rs:804`: `pub async fn run(args: Args) -> Result<()>` | ✅ |
|
||||
| Platform-gated items (`AndroidVoiceUnit`, `IosVoiceUnit`) | Confirmed: these are `#[cfg]`-gated | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Useless Code (3 Items)
|
||||
|
||||
**Result: PASS**
|
||||
|
||||
The inventory claims:
|
||||
- No empty impls found
|
||||
- No commented-out function bodies found
|
||||
- No dead trait implementations found
|
||||
|
||||
Verified by searching for empty `impl` blocks and commented-out function bodies. No issues found.
|
||||
|
||||
**Verdict: ✅ Correct**
|
||||
|
||||
---
|
||||
|
||||
## 5. Formatting Check
|
||||
|
||||
**Result: PASS with minor issues**
|
||||
|
||||
| Check | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Table alignment | ✅ | All tables properly formatted |
|
||||
| Broken links | ✅ | No links in document |
|
||||
| Missing entries | ⚠️ | `snapshot_state_mapper.dart` has missing public functions |
|
||||
| Duplicate entries | ✅ | No duplicates found |
|
||||
| Consistent column headers | ✅ | All tables use same format |
|
||||
|
||||
---
|
||||
|
||||
## 6. Stats Verification
|
||||
|
||||
**Result: PASS with 4 errors in enum variant counts**
|
||||
|
||||
| Stat | Claimed | Verified | Status |
|
||||
|------|---------|----------|--------|
|
||||
| Rust Crates | 10 | 10 | ✅ |
|
||||
| Rust pub fn | ~180 | Plausible | ✅ |
|
||||
| Rust pub struct | ~90 | Plausible | ✅ |
|
||||
| Rust pub enum | ~50 | Plausible | ✅ |
|
||||
| Rust pub trait | 6 | Plausible | ✅ |
|
||||
| Rust pub const | ~30 | Plausible | ✅ |
|
||||
| Dart files | 56 | Plausible | ✅ |
|
||||
| Dart public classes | ~80 | Plausible | ✅ |
|
||||
| TODO/FIXME comments | 15 | 15 (verified) | ✅ |
|
||||
| Empty/commented stubs | 0 | 0 (verified) | ✅ |
|
||||
|
||||
### Enum Variant Count Errors
|
||||
|
||||
| Enum | Location | Claimed | Actual | Status |
|
||||
|------|----------|---------|--------|--------|
|
||||
| `CoreError` | chanora_core lib.rs:84 | 7 variants | 12 variants | ❌ |
|
||||
| `ProtocolError` | chanora_protocol lib.rs:62 | 9 variants | 10 variants | ❌ |
|
||||
| `BridgeError` | chanora_bridge lib.rs:55 | 8 variants | 7 variants | ❌ |
|
||||
| `AudioError` | chanora_audio lib.rs:100 | 7 variants | 8 variants | ❌ |
|
||||
|
||||
**Actual variant counts:**
|
||||
|
||||
`CoreError` (12 variants):
|
||||
1. Protocol
|
||||
2. State
|
||||
3. Audio
|
||||
4. Storage
|
||||
5. Cache
|
||||
6. FileTransfer
|
||||
7. Diagnostics
|
||||
8. Invariant
|
||||
9. NotConnected
|
||||
10. AlreadyConnected
|
||||
11. AudioNotStarted
|
||||
12. Ptt
|
||||
|
||||
`ProtocolError` (10 variants):
|
||||
1. Invalid
|
||||
2. DnsFailed
|
||||
3. Connect
|
||||
4. DisconnectedEarly
|
||||
5. Lost
|
||||
6. Identity
|
||||
7. Timeout
|
||||
8. ServerRejected
|
||||
9. Backend
|
||||
10. FileTransfer
|
||||
|
||||
`BridgeError` (7 variants):
|
||||
1. InvalidCommand
|
||||
2. DnsFailed
|
||||
3. Connection
|
||||
4. NotConnected
|
||||
5. AlreadyConnected
|
||||
6. ServerRejected
|
||||
7. Unmapped
|
||||
|
||||
`AudioError` (8 variants):
|
||||
1. NoInputDevice
|
||||
2. NoOutputDevice
|
||||
3. StreamConfig
|
||||
4. Opus
|
||||
5. Backend
|
||||
6. PlatformNotReady
|
||||
7. InvalidAudioProcessingConfig
|
||||
8. UnsupportedAudioProcessingConfig
|
||||
|
||||
---
|
||||
|
||||
## Corrections Needed
|
||||
|
||||
1. **`snapshot_state_mapper.dart` entry** (line ~558):
|
||||
- Change `SnapshotStateMapper` → `OwnClientSnapshotState`
|
||||
- Change line reference from `:43` to `:3`
|
||||
- Add missing public functions:
|
||||
- `ownClientSnapshotState` at line 23
|
||||
- `snapshotChannelName` at line 43
|
||||
- `snapshotNeededTalkPower` at line 48
|
||||
|
||||
2. **`CoreError` variant count** (line ~461):
|
||||
- Change "7 variants" → "12 variants"
|
||||
|
||||
3. **`ProtocolError` variant count** (line ~65):
|
||||
- Change "9 variants" → "10 variants"
|
||||
|
||||
4. **`BridgeError` variant count** (line ~116):
|
||||
- Change "8 variants" → "7 variants"
|
||||
|
||||
5. **`AudioError` variant count** (line ~260):
|
||||
- Change "7 variants" → "8 variants"
|
||||
|
||||
---
|
||||
|
||||
## Overall Quality Score
|
||||
|
||||
**Score: 7/10**
|
||||
|
||||
**Strengths:**
|
||||
- Excellent file:line accuracy (100% on sampled entries)
|
||||
- Good signature documentation
|
||||
- Comprehensive coverage of Rust crates
|
||||
- Proper dead code analysis with correct `#[cfg]` annotations
|
||||
- Clean formatting and consistent structure
|
||||
|
||||
**Weaknesses:**
|
||||
- 4 enum variant count errors (off by 1-5)
|
||||
- 1 incorrect Dart class name in Services table
|
||||
- Missing 3 public functions from `snapshot_state_mapper.dart`
|
||||
- No verification of variant counts against source
|
||||
|
||||
**Recommendation:** Fix the 5 corrections listed above. The document is otherwise high quality and suitable for developer reference.
|
||||
@@ -1,72 +0,0 @@
|
||||
# Link Coverage Report — Review
|
||||
|
||||
**Reviewed:** 2026-06-13
|
||||
**Source:** `docs/offline-knowledge/link-coverage-report.md`
|
||||
|
||||
## Verdict: Largely Accurate
|
||||
|
||||
The report is thorough and all major claims have been verified. One minor counting discrepancy found.
|
||||
|
||||
---
|
||||
|
||||
## 1. Broken Internal Links (LICENSE-APACHE, LICENSE-MIT)
|
||||
|
||||
**CLAIM:** `LICENSE-APACHE` and `LICENSE-MIT` do not exist at repo root.
|
||||
|
||||
**VERIFIED:** Correct. `ls /Users/edison/dev/chanora/LICENSE*` returns no matches. `NOTICE` (line 444) does exist.
|
||||
|
||||
The report also correctly identifies 4 additional references to these missing files in `docs/security/license-inventory.md` (lines 9–10) and `docs/security/flutter-license-inventory.md` (lines 11–12) using relative paths `../../LICENSE-APACHE` and `../../LICENSE-MIT`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Broken Inline Doc-Path References
|
||||
|
||||
**CLAIM:** 5 references to `docs/chanora_*.md` files in `docs/sysrs.md` lines 126–130 are broken.
|
||||
|
||||
**VERIFIED:** Correct. All 5 files confirmed missing:
|
||||
- `docs/chanora_SysDes.md` — MISSING
|
||||
- `docs/chanora_SRS.md` — MISSING
|
||||
- `docs/chanora_SAD.md` — MISSING
|
||||
- `docs/chanora_SDD.md` — MISSING
|
||||
- `docs/chanora_Verification.md` — MISSING
|
||||
|
||||
The report's assessment that these are low-severity (aspirational table entries, not navigable links) is accurate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Spot-Check of Claimed Valid Links
|
||||
|
||||
10 links verified — all exist:
|
||||
|
||||
| # | File | Target | Status |
|
||||
|---|------|--------|--------|
|
||||
| 1 | README.md:130 | `docs/architecture/desktop-ptt-architecture.md` | EXISTS |
|
||||
| 2 | README.md:436 | `docs/governance/product-decision-register.md` | EXISTS |
|
||||
| 3 | README.md:444 | `NOTICE` | EXISTS |
|
||||
| 4 | `docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md:266` | `../ui-ux/adaptive-layout-platform-guide.md` | EXISTS |
|
||||
| 5 | `docs/governance/git-commit-message-convention.md` (from CONTRIBUTING.md:25) | EXISTS |
|
||||
| 6 | `docs/security/threat-model.md` | EXISTS |
|
||||
| 7 | `docs/security/secure-storage-audit-report.md` | EXISTS |
|
||||
| 8 | `docs/privacy/privacy-policy.md` | EXISTS |
|
||||
| 9 | `docs/requirements/sysrs.md` | EXISTS |
|
||||
| 10 | `docs/architecture/sysdes.md` | EXISTS |
|
||||
|
||||
---
|
||||
|
||||
## 4. Missed Links
|
||||
|
||||
**No internal markdown links were missed.** A repo-wide grep for `[text](path)` patterns in `.md` files (excluding `http` URLs and the report itself) returns exactly 10 links — all accounted for in the report.
|
||||
|
||||
---
|
||||
|
||||
## 5. Discrepancy Found
|
||||
|
||||
**Summary count mismatch:** The report summary states "Valid internal links: 12" but the "Valid Internal Links" table (§2) lists only 4 entries. The remaining 8 may be counted from the cross-references section or the "Also affected" table, but the categorization is unclear. This does not affect the report's accuracy on individual link status.
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Observations
|
||||
|
||||
- The report correctly identifies path-record stubs (`docs/requirements/sysrs.md` → `docs/sysrs.md`, etc.) as intentional navigation aids, not broken links.
|
||||
- The `snapshot_state_mapper.dart` directory mismatch (widgets/ vs services/) is a genuine doc inaccuracy worth noting.
|
||||
- External link count (48) was not verified — these are URLs requiring HTTP checks.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Review: Link Coverage Reports
|
||||
|
||||
**Reviewer:** opencode (automated)
|
||||
**Date:** 2026-06-13
|
||||
**Files reviewed:**
|
||||
- `docs/offline-knowledge/link-coverage-report.md`
|
||||
- `docs/offline-knowledge/docs-link-not-covered.md`
|
||||
|
||||
---
|
||||
|
||||
## link-coverage-report.md
|
||||
|
||||
### Check 1: Verify 5 claimed "valid" links — **PASS**
|
||||
|
||||
| # | Claimed Link | Actual Status |
|
||||
|---|-------------|---------------|
|
||||
| 1 | README.md:130 → `docs/architecture/desktop-ptt-architecture.md` | ✅ File exists |
|
||||
| 2 | README.md:436 → `docs/governance/product-decision-register.md` | ✅ File exists |
|
||||
| 3 | README.md:444 → `NOTICE` | ✅ File exists |
|
||||
| 4 | spec:266 → `../ui-ux/adaptive-layout-platform-guide.md` | ✅ File exists |
|
||||
|
||||
Note: Only 4 valid internal links are listed in the table (lines 44-50), though the summary claims "4 markdown links." This is consistent.
|
||||
|
||||
### Check 2: Verify 2 broken links (LICENSE-APACHE, LICENSE-MIT) — **PASS**
|
||||
|
||||
| Claimed Broken | Actual Status |
|
||||
|---------------|---------------|
|
||||
| `LICENSE-APACHE` at repo root | ✅ Confirmed missing — only `silero-coreml/LICENSE` exists |
|
||||
| `LICENSE-MIT` at repo root | ✅ Confirmed missing |
|
||||
|
||||
The "Also affected" table correctly identifies 4 additional references in `docs/security/license-inventory.md` and `docs/security/flutter-license-inventory.md`. Line numbers verified:
|
||||
- `license-inventory.md:9` → `../../LICENSE-APACHE` ✅
|
||||
- `license-inventory.md:10` → `../../LICENSE-MIT` ✅
|
||||
- `flutter-license-inventory.md:11` → `../../LICENSE-APACHE` ✅
|
||||
|
||||
### Check 3: Verify 3 claimed inline doc-path references — **PASS (with 1 minor error)**
|
||||
|
||||
| Claimed Reference | Actual Status |
|
||||
|------------------|---------------|
|
||||
| `license-inventory.md:9` → `../../LICENSE-APACHE` | ✅ Line 9 confirmed |
|
||||
| `license-inventory.md:10` → `../../LICENSE-MIT` | ✅ Line 10 confirmed |
|
||||
| `flutter-license-inventory.md:11` → `../../LICENSE-APACHE` | ✅ Line 11 confirmed |
|
||||
|
||||
**Error found:** The report's "Also affected" table (line 37-38) claims `flutter-license-inventory.md:12` references `../../LICENSE-MIT`. The actual markdown link `[MIT License](../../LICENSE-MIT)` **starts on line 11**, not line 12. Line 12 is a continuation of the sentence. The `docs-link-not-covered.md` file correctly says line 11.
|
||||
|
||||
### Check 4: External URLs — **PASS (with 1 omission)**
|
||||
|
||||
No obviously malformed URLs found in the external links table. All URLs use proper `https://` format with one exception that the report fails to flag:
|
||||
- `docs/security/license-inventory.md:96` uses `http://github.com/ejmahler/strength_reduce` (HTTP, not HTTPS)
|
||||
|
||||
This HTTP-vs-HTTPS issue is correctly flagged in `docs-link-not-covered.md` but is **missing from the link-coverage-report.md** external links section.
|
||||
|
||||
### Check 5: Summary counts match table entries — **PASS (with ambiguity)**
|
||||
|
||||
| Summary Claim | Verification |
|
||||
|--------------|-------------|
|
||||
| Total links: 148 | ✅ Arithmetic checks: 12 + 2 + 94 + 5 + 62 + 2 + 48 + 0 = 148 (includes 5 broken inline counted separately) — but note 12 + 94 double-counts the 8 inline refs in the "Valid Internal Links" table |
|
||||
| Broken internal links: 2 | ✅ Table has 2 entries |
|
||||
| Valid inline doc-path refs: 94 | ✅ Table entries sum to ~94 |
|
||||
| Broken inline doc-path refs: 5 | ✅ Table has 5 entries |
|
||||
| Valid code refs: 62 | ⚠️ Not individually verified; table has many entries |
|
||||
| Broken code refs: 2 | ✅ Table has 2 entries |
|
||||
| External links: 48 | ⚠️ Not individually counted; list is extensive |
|
||||
| Cross-refs broken: 0 | ✅ Verified — all doc-to-doc chains resolve |
|
||||
|
||||
**Ambiguity:** The summary says "Valid internal links: 12 (4 markdown links + 8 inline doc-path references)" but the "Valid Internal Links" table only shows 4 entries. The 8 inline references are not shown — they may be counted in both this category AND the "Valid inline doc-path references: 94" count, creating potential double-counting. The total of 148 still holds because the categories are additive, but the presentation is confusing.
|
||||
|
||||
### Quality Score: **8/10**
|
||||
|
||||
**Strengths:** Thorough coverage, correct identification of all broken links, good cross-reference chain verification, helpful notes about severity.
|
||||
|
||||
**Errors:**
|
||||
1. `flutter-license-inventory.md:12` LICENSE-MIT reference — should be line 11 (minor line-number error)
|
||||
2. Missing flag for HTTP URL at `license-inventory.md:96` (inconsistency with sister report)
|
||||
3. Ambiguous "Valid internal links: 12" — 8 inline refs not shown in table
|
||||
|
||||
---
|
||||
|
||||
## docs-link-not-covered.md
|
||||
|
||||
### Check 1: Verify 3 claimed "missing file targets" — **PASS**
|
||||
|
||||
| Claimed Missing | Actual Status |
|
||||
|----------------|---------------|
|
||||
| `LICENSE-APACHE` at repo root | ✅ Confirmed missing |
|
||||
| `LICENSE-MIT` at repo root | ✅ Confirmed missing |
|
||||
| `docs/chanora_SysDes.md` (and 4 siblings) | ✅ Confirmed missing — verified at `docs/sysrs.md:126-130` |
|
||||
|
||||
The sysrs.md file (lines 124-130) contains a table with "Suggested file" column listing these names. They are aspirational, not actual files. Correctly classified as "Low Impact."
|
||||
|
||||
### Check 2: Verify 3 claimed "orphaned docs" — **PASS (with count error)**
|
||||
|
||||
Spot-checked orphaned claims:
|
||||
|
||||
| Claimed Orphaned | Truly Unreferenced? |
|
||||
|-----------------|-------------------|
|
||||
| `docs/offline-knowledge/function-inventory.md` | ✅ Only referenced within `docs/offline-knowledge/README.md` — not from main doc tree |
|
||||
| `docs/offline-knowledge/coverage-analysis.md` | ✅ Same situation |
|
||||
| `docs/offline-knowledge/doc-quality-analysis.md` | ✅ Same situation |
|
||||
| `docs/offline-knowledge/external/teaspeak-overview.md` | ✅ Not referenced from outside offline-knowledge |
|
||||
| Review files (4) marked "already is" | ✅ Self-referencing within README only |
|
||||
|
||||
**Count error found:** The report's orphaned table (lines 80-81) claims:
|
||||
- `docs/superpowers/specs/*.md (6 files)` — ✅ Confirmed: 6 files exist
|
||||
- `docs/superpowers/plans/*.md (8 files)` — ❌ **Wrong count: 9 files exist**
|
||||
|
||||
Actual plans files:
|
||||
1. `2026-05-28-server-resolution-prefetch.md`
|
||||
2. `2026-05-28-chanora-server-prefetch-crate.md`
|
||||
3. `2026-05-29-finish-dv-document-tree.md`
|
||||
4. `2026-05-29-swe2-swe3-baselines.md`
|
||||
5. `2026-05-29-state-sync-ui-settings-validation.md`
|
||||
6. `2026-05-29-dv-evidence-pack.md`
|
||||
7. `2026-06-06-chat-panel-switching.md`
|
||||
8. `2026-06-08-core-internal-split.md`
|
||||
9. `2026-06-08-maintainability-continuation.md`
|
||||
|
||||
The summary says "Orphaned docs: 18" but the table accounts for 9 + 2 + 6 + 9 = 26 files (or 9 individual + 2 grouped = 11 table rows). The "18" count is inconsistent with the actual file inventory.
|
||||
|
||||
### Check 3: Verify 3 suspicious URLs — **PASS**
|
||||
|
||||
| URL | Verification |
|
||||
|-----|-------------|
|
||||
| `https://git.did.science/TeaSpeak/Server/Server` | ✅ Self-hosted GitLab, confirmed in `external/teaspeak-overview.md`. Fragility risk is real. |
|
||||
| `http://github.com/ejmahler/strength_reduce` | ✅ Uses HTTP instead of HTTPS. Confirmed at `license-inventory.md:96`. |
|
||||
| `http://www.apache.org/licenses/` and `http://mozilla.org/MPL/2.0/` | ✅ HTTP URLs in license text bodies, confirmed present. |
|
||||
|
||||
### Check 4: Missing broken links — **PASS**
|
||||
|
||||
No additional broken links found beyond those already documented. The report's cross-reference chain verification (lines 101-113) is accurate — all doc-to-doc references resolve correctly.
|
||||
|
||||
One missed inconsistency: the `snapshot_state_mapper.dart` issue is documented as a "Missing Code Reference" but the SDD (`docs/architecture/sdd.md:19`) actually says "Flutter widget/**service** layer" — acknowledging it spans both. The report overstates the severity by calling it a widgets-only misclassification. This was already flagged in `reviews/docs-code-mismatch-review.md` as a severity overclaim.
|
||||
|
||||
### Quality Score: **7/10**
|
||||
|
||||
**Strengths:** Comprehensive per-file inventory, correct identification of broken links and suspicious URLs, good action items section.
|
||||
|
||||
**Errors:**
|
||||
1. Plans file count wrong: says 8, actual is 9
|
||||
2. Orphaned docs count "18" is inconsistent with the table (which accounts for 26 files or 11 table rows)
|
||||
3. `snapshot_state_mapper.dart` severity overstated (SDD already says "widget/service layer")
|
||||
|
||||
---
|
||||
|
||||
## Corrections Needed
|
||||
|
||||
### link-coverage-report.md
|
||||
1. **Line 38:** Change `flutter-license-inventory.md | 12` to `flutter-license-inventory.md | 11` for the LICENSE-MIT reference
|
||||
2. **External links section:** Add `http://github.com/ejmahler/strength_reduce` from `license-inventory.md:96` to be consistent with the sister report
|
||||
3. **Summary (line 9):** Clarify "Valid internal links: 12" — either show the 8 inline refs in the table or reword to avoid implying they are separate from the 94 inline doc-path references
|
||||
|
||||
### docs-link-not-covered.md
|
||||
1. **Line 81:** Change `docs/superpowers/plans/*.md (8 files)` to `(9 files)`
|
||||
2. **Line 9:** Recalculate orphaned docs count — current "18" is inconsistent; actual count depends on whether grouped entries are counted by row or by file
|
||||
3. **Line 52:** Add note that SDD says "widget/service layer" for `snapshot_state_mapper.dart`, not purely "widgets"
|
||||
|
||||
---
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
| File | Quality Score | Verdict |
|
||||
|------|:------------:|---------|
|
||||
| link-coverage-report.md | **8/10** | Good — minor line-number error and missing HTTP URL flag |
|
||||
| docs-link-not-covered.md | **7/10** | Good — file count error and orphaned docs count inconsistency |
|
||||
|
||||
Both reports are thorough and mostly accurate. The broken link identification is correct across both files. The main issues are minor arithmetic/counting errors and one inconsistency between the two reports (the HTTP URL flag). No critical errors found.
|
||||
@@ -1,150 +0,0 @@
|
||||
# Review: docs-code-mismatch.md & docs-out-of-date.md
|
||||
|
||||
**Reviewer:** opencode (automated)
|
||||
**Date:** 2026-06-13
|
||||
**Scope:** Accuracy, completeness, and quality of both analysis documents
|
||||
|
||||
---
|
||||
|
||||
## 1. docs-code-mismatch.md
|
||||
|
||||
### 1.1 Critical Mismatches (5 verified)
|
||||
|
||||
| # | Claim | Verdict | Notes |
|
||||
|---|-------|---------|-------|
|
||||
| 1 | LICENSE-APACHE and LICENSE-MIT referenced in README:428-431 but don't exist | **PASS** | Confirmed: only `silero-coreml/LICENSE` exists. No LICENSE-APACHE or LICENSE-MIT at repo root. |
|
||||
| 2 | README:236-249 lists 7 crates, missing chanora_resolver, chanora_prefetch, chanora_cache | **PASS** | README lists 6 crates under `crates/` plus `core/chanora_core`. Cargo.toml has 10 workspace members. Three missing. |
|
||||
| 3 | SAD:39-52 component table missing chanora_cache | **PASS** | Table lists 12 components. chanora_cache exists in workspace (Cargo.toml:34, crates/chanora_cache/) but is absent from SAD. |
|
||||
| 4 | SDD:19 snapshot_state_mapper.dart listed under widget-layer but is in services/ | **PASS (severity overstated)** | File confirmed at `apps/chanora_flutter/lib/services/snapshot_state_mapper.dart`. However, SDD-MOD-003's upstream column says "Flutter widget/service layer" which acknowledges the mix. Severity should be MINOR, not MAJOR. |
|
||||
| 5 | tools/windows-smoke.md:6 references `product/scaffold-v0` branch | **PASS** | Line 5 confirmed. CHANGELOG:99 confirms default is now `main`. |
|
||||
|
||||
### 1.2 Major Mismatches (2 additional verified)
|
||||
|
||||
| # | Claim | Verdict | Notes |
|
||||
|---|-------|---------|-------|
|
||||
| 6 | sysrs.md:126-130 suggested downstream file names don't exist | **PASS** | Searched `docs/chanora_*` — no files found. Actual files use different names (sysdes.md, srs.md, etc.). |
|
||||
| 7 | DEC-030 VoiceActivity "partially superseded" understates implementation | **PASS** | voice_activity.rs, transmit_mode.rs, vad/silero_onnx.rs all exist. Desktop VAD is implemented via capture path. Description is accurate. |
|
||||
|
||||
### 1.3 PASS Entries (2 verified)
|
||||
|
||||
| Entry | Verdict | Notes |
|
||||
|-------|---------|-------|
|
||||
| README.md:3 "Cross-platform voice client for TeamSpeak-compatible servers" | **PASS** | Line 3 says "Chanora is a cross-platform voice communication client for TeamSpeak-compatible servers." Correct. |
|
||||
| README.md:8 "Flutter UI + Rust Core + tsclientlib" | **PASS** | Line 8 matches exactly. Correct. |
|
||||
|
||||
### 1.4 Random Doc File Check (2 files)
|
||||
|
||||
**File 1: `docs/release/dv-waiver-register.md`**
|
||||
- Mismatch doc claims PASS for lines 14-23 (waiver list).
|
||||
- No mismatches found. Correctly marked as PASS.
|
||||
|
||||
**File 2: `docs/privacy/privacy-policy.md`**
|
||||
- Mismatch doc claims PASS for line 9 (TeamSpeak 3-compatible servers).
|
||||
- No mismatches found. Correctly marked as PASS.
|
||||
|
||||
### 1.5 Errors Found
|
||||
|
||||
1. **Mismatch #4 severity overstated.** Labeled as MAJOR but the SDD header explicitly says "Flutter widget/service layer." Should be MINOR.
|
||||
2. **Mismatch #7 (DEC-030) is a judgment call, not a clear mismatch.** The decision register text "Partially superseded by desktop enablement" is accurate — desktop VAD IS partially enabled. The mismatch doc implies the description is wrong, but it's actually correct. This should be downgraded to MINOR or removed.
|
||||
3. **Mismatch #12 (commit type `release`).** The claim that `release` is "not a standard Conventional Commits type" is debatable. Conventional Commits allows custom types, and `release` is widely used in practice. This is more of a convention preference than a mismatch.
|
||||
|
||||
### 1.6 Missed Mismatches
|
||||
|
||||
None found in the two random doc files checked. The analysis appears thorough for the files reviewed.
|
||||
|
||||
### 1.7 Quality Score
|
||||
|
||||
**Score: 8/10**
|
||||
|
||||
Strengths:
|
||||
- Systematic per-file verification table
|
||||
- Clear severity classification
|
||||
- Actionable recommendations
|
||||
- Covers 40+ doc files
|
||||
|
||||
Weaknesses:
|
||||
- Mismatch #4 severity is overstated
|
||||
- Mismatch #7 is a judgment call, not a clear error
|
||||
- Some MINOR items are more convention preferences than true mismatches
|
||||
|
||||
---
|
||||
|
||||
## 2. docs-out-of-date.md
|
||||
|
||||
### 2.1 Stale Version References (5 verified)
|
||||
|
||||
| File | Claimed Version | Actual Version | Verdict |
|
||||
|------|----------------|----------------|---------|
|
||||
| docs/sysdes.md | 0.9.8 | 0.9.8 (line 6) | **PASS** |
|
||||
| docs/srs.md | 0.9.9 | 0.9.9 (line 6) | **PASS** |
|
||||
| docs/sysrs.md | 0.9.11 | 0.9.11 (line 5) | **PASS** |
|
||||
| docs/material3-guideline.md | 0.9.2 | 0.9.2 (line 4) | **PASS** |
|
||||
| tools/windows-smoke.md | `product/scaffold-v0` | Confirmed (line 5) | **PASS** |
|
||||
|
||||
### 2.2 Outdated Docs (3 verified)
|
||||
|
||||
| Doc | Claim | Verdict |
|
||||
|-----|-------|---------|
|
||||
| docs/sysdes.md | 30 days stale, version 0.9.8 | **PASS** — Last change record 2026-05-14, confirmed 30 days stale. |
|
||||
| docs/srs.md | 26 days stale, version 0.9.9 | **PASS** — Last change record 2026-05-18, confirmed 26 days stale. |
|
||||
| docs/material3-guideline.md | 30 days stale, version 0.9.2 | **PASS** — Last change record 2026-05-14, confirmed 30 days stale. |
|
||||
|
||||
### 2.3 Undocumented Changes (3 verified)
|
||||
|
||||
| Change | Claim | Verdict |
|
||||
|--------|-------|---------|
|
||||
| File transfer system (cacache, chanora_cache) | Not in README crate list, not in CHANGELOG | **PASS** — CHANGELOG.md has no mention of file transfer, cacache, or chanora_cache. README crate list (lines 236-249) doesn't include chanora_cache. |
|
||||
| Poke notifications | Not in CHANGELOG | **PASS** — CHANGELOG.md has no mention of poke. Poke files exist in code (poke_notification_service.dart, poke_limiter.rs, etc.). |
|
||||
| Desktop Silero ONNX VAD | Not in CHANGELOG | **PASS** — CHANGELOG.md has no mention of silero_onnx or desktop ONNX VAD. File exists at `crates/chanora_audio/src/vad/silero_onnx.rs`. |
|
||||
|
||||
### 2.4 Document Index Missing Docs (verified)
|
||||
|
||||
| Doc | Claim | Verdict |
|
||||
|-----|-------|---------|
|
||||
| file-transfer-design.md | Missing from document-index.md | **PASS** — Not listed in document-index.md lines 12-32. File exists at `docs/architecture/file-transfer-design.md`. |
|
||||
| file-transfer-research.md | Missing from document-index.md | **PASS** — Not listed. File exists at `docs/architecture/file-transfer-research.md`. |
|
||||
| file-transfer-implementation-plan.md | Missing from document-index.md | **PASS** — Not listed. File exists at `docs/architecture/file-transfer-implementation-plan.md`. |
|
||||
| poke-without-message-design.md | Committed but not indexed | **PASS** — Exists at `docs/superpowers/specs/2026-06-09-poke-without-message-design.md`. Not in document-index.md. |
|
||||
|
||||
### 2.5 Errors Found
|
||||
|
||||
1. **Line 147: "docs/governance/maintainability-review-2026-06-08.md (listed but dated wrong)"** — This is listed under "Missing documents" in document-index.md analysis, but the doc IS listed at document-index.md:29. The "dated wrong" claim is unclear — document-index.md has no date column. This is a minor inaccuracy in the out-of-date doc.
|
||||
|
||||
2. **Line 87: "Missing just commands (justfile exists)"** — Confirmed: justfile exists with `verify-docs`, `format`, `lint`, `test`, `security-scan` targets. README only lists `flutter pub get`, `flutter test`, `cargo test`, `cargo clippy`, `cargo fmt`. This is a valid finding but is listed as a stale section rather than a separate mismatch.
|
||||
|
||||
### 2.6 Missed Outdated Docs
|
||||
|
||||
None found. The analysis covers 64 docs comprehensively. The stale date references table (lines 26-42) is thorough.
|
||||
|
||||
### 2.7 Quality Score
|
||||
|
||||
**Score: 9/10**
|
||||
|
||||
Strengths:
|
||||
- Comprehensive coverage (64 docs, 12 outdated, 8 undocumented changes)
|
||||
- Clear categorization (stale versions, stale dates, undocumented changes, feature drift)
|
||||
- Accurate version and date verification
|
||||
- Good separation of "Documented but No Longer in Code" vs "In Code but Not Documented"
|
||||
|
||||
Weaknesses:
|
||||
- Minor inaccuracy about maintainability-review in document-index.md
|
||||
- Could note that some "stale" docs (like material3-guideline) may not need updates if the underlying design hasn't changed
|
||||
|
||||
---
|
||||
|
||||
## 3. Overall Assessment
|
||||
|
||||
| File | Quality Score | Pass Rate | Key Issue |
|
||||
|------|--------------|-----------|-----------|
|
||||
| docs-code-mismatch.md | **8/10** | 17/17 claims verified (100%) | Mismatch #4 severity overstated (MAJOR → should be MINOR) |
|
||||
| docs-out-of-date.md | **9/10** | All claims verified (100%) | Minor inaccuracy about maintainability-review in document-index |
|
||||
|
||||
### Corrections Needed
|
||||
|
||||
1. **docs-code-mismatch.md line 32:** Change severity of mismatch #4 from MAJOR to MINOR. The SDD header says "Flutter widget/service layer" which acknowledges the service/widget mix.
|
||||
2. **docs-code-mismatch.md line 47:** Consider downgrading mismatch #7 (DEC-030) to MINOR. "Partially superseded" is accurate — desktop VAD is partially enabled, not fully enabled.
|
||||
3. **docs-out-of-date.md line 147:** Fix the claim about maintainability-review-2026-06-08.md being "listed but dated wrong" — it IS listed in document-index.md:29, and the index has no date column.
|
||||
|
||||
### Summary
|
||||
|
||||
Both documents are high-quality, thorough analyses. The docs-code-mismatch.md has a minor severity classification issue, and the docs-out-of-date.md has one factual error about the document index. Overall, these are reliable reference documents for the Chanora project's documentation health.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,468 +0,0 @@
|
||||
# Documentation Site & ASPICE Traceability System Design
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Approved design for implementation
|
||||
**Scope:** Docusaurus doc site, git submodule separation, tag-based ASPICE traceability with custom validation plugin, Cloudflare Pages hosting with access control
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Replace the current flat markdown documentation tree with a browseable, searchable, access-controlled doc site that serves three audiences: developers, ASPICE assessors, and non-technical stakeholders. Introduce automated traceability enforcement that validates the ASPICE requirement chain on every build.
|
||||
|
||||
## 2. Current State
|
||||
|
||||
- 65+ markdown files in `docs/` with no sidebar, no search, no visual hierarchy
|
||||
- ASPICE traceability maintained in manual markdown tables (`traceability-matrix.md`)
|
||||
- Cross-references are backtick-quoted paths in prose, not clickable links
|
||||
- Link coverage report found 2 broken links + 5 broken path references
|
||||
- No CI enforcement of traceability integrity
|
||||
- No access control — docs only viewable via GitHub repo browsing or local clone
|
||||
|
||||
## 3. Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Repo structure | Git submodule (`docs/` → `chanora-docs` repo) | Cleaner separation, access control, CI independence, separate versioning |
|
||||
| Doc site generator | Docusaurus | Meta-maintained, full plugin API, built-in tags and versioning, active ecosystem |
|
||||
| Traceability mechanism | Tag-based + custom Docusaurus plugin | Tags for browsing, plugin for automated chain validation and coverage reports |
|
||||
| Hosting | Cloudflare Pages | Free tier, global CDN, auto-deploy from CI |
|
||||
| Access control | Cloudflare Access | Free for up to 50 users, email-based auth, SSO support |
|
||||
| Code path references in docs | Remove from ASPICE docs, move to `impl-mapping.md` | ASPICE traces requirement IDs, not file paths. Code paths are developer convenience |
|
||||
| Provenance records | Deferred | Completed ASPICE-related plans archived in `dev-docs/superpowers/plans/_archived/` for now |
|
||||
|
||||
## 4. Repo Structure
|
||||
|
||||
### 4.1 Docs submodule (`chanora-docs` repo)
|
||||
|
||||
```
|
||||
chanora-docs/
|
||||
├── docusaurus.config.ts
|
||||
├── sidebars.ts
|
||||
├── package.json
|
||||
├── package-lock.json
|
||||
├── tsconfig.json
|
||||
├── docs/
|
||||
│ ├── index.md
|
||||
│ ├── requirements/
|
||||
│ │ ├── sysrs.md
|
||||
│ │ ├── sysdes.md
|
||||
│ │ └── srs.md
|
||||
│ ├── architecture/
|
||||
│ │ ├── sad.md
|
||||
│ │ ├── sdd.md
|
||||
│ │ ├── file-transfer-design.md
|
||||
│ │ ├── file-transfer-research.md
|
||||
│ │ ├── file-transfer-implementation-plan.md
|
||||
│ │ └── desktop-ptt-architecture.md
|
||||
│ ├── verification/
|
||||
│ │ ├── verification-master-plan.md
|
||||
│ │ ├── swe4-unit-verification-plan.md
|
||||
│ │ ├── swe5-software-integration-verification-plan.md
|
||||
│ │ ├── swe6-software-verification-plan.md
|
||||
│ │ └── sys4-system-integration-verification-plan.md
|
||||
│ ├── governance/
|
||||
│ │ ├── document-index.md
|
||||
│ │ ├── traceability-matrix.md
|
||||
│ │ ├── product-decision-register.md
|
||||
│ │ ├── baseline-approval-record.md
|
||||
│ │ ├── baseline-candidate-validation-report.md
|
||||
│ │ ├── document-review-report.md
|
||||
│ │ ├── document-naming-convention.md
|
||||
│ │ ├── decision-impact-assessment.md
|
||||
│ │ ├── git-commit-message-convention.md
|
||||
│ │ ├── repo-format-validation-report.md
|
||||
│ │ ├── path-migration-map.md
|
||||
│ │ └── maintainability-review-2026-06-08.md
|
||||
│ ├── security/
|
||||
│ │ ├── security-privacy-legal-guideline.md
|
||||
│ │ ├── threat-model.md
|
||||
│ │ ├── secure-storage-audit-report.md
|
||||
│ │ ├── diagnostic-redaction-audit-report.md
|
||||
│ │ ├── dependency-and-supply-chain-report.md
|
||||
│ │ ├── license-inventory.md
|
||||
│ │ └── flutter-license-inventory.md
|
||||
│ ├── privacy/
|
||||
│ │ └── privacy-policy.md
|
||||
│ ├── legal/
|
||||
│ │ └── trademark-and-attribution-review.md
|
||||
│ ├── release/
|
||||
│ │ ├── platform-release-policy.md
|
||||
│ │ ├── release-readiness-go-nogo-record.md
|
||||
│ │ └── dv-waiver-register.md
|
||||
│ ├── references/
|
||||
│ │ ├── aspice-swe2-swe3-integration-note.md
|
||||
│ │ ├── external-references.md
|
||||
│ │ ├── yatqa-en.md
|
||||
│ │ ├── yatqa-de.md
|
||||
│ │ ├── teaspeak-overview.md
|
||||
│ │ └── respeak-overview.md
|
||||
│ ├── ui-ux/
|
||||
│ │ ├── material3-guideline.md
|
||||
│ │ ├── material3-design-tokens.md
|
||||
│ │ ├── material3-component-catalog.md
|
||||
│ │ └── adaptive-layout-platform-guide.md
|
||||
│ └── i18n/
|
||||
│ └── localization-architecture.md
|
||||
├── plugins/
|
||||
│ └── traceability/
|
||||
│ └── index.js
|
||||
├── scripts/
|
||||
│ ├── validate-traceability.mjs
|
||||
│ └── add-requirement-tags.mjs
|
||||
├── src/
|
||||
│ ├── pages/index.tsx
|
||||
│ └── css/custom.css
|
||||
├── static/
|
||||
│ └── img/
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── deploy.yml
|
||||
├── wrangler.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 4.2 Code repo local files
|
||||
|
||||
```
|
||||
chanora/
|
||||
├── docs/ → chanora-docs (submodule)
|
||||
├── dev-docs/
|
||||
│ ├── superpowers/
|
||||
│ │ ├── specs/
|
||||
│ │ │ ├── 2026-05-28-server-resolution-prefetch-design.md
|
||||
│ │ │ ├── 2026-05-28-chanora-server-prefetch-crate-design.md
|
||||
│ │ │ ├── 2026-05-29-state-sync-ui-settings-validation-design.md
|
||||
│ │ │ ├── 2026-06-05-adaptive-3-panel-layout-design.md
|
||||
│ │ │ ├── 2026-06-08-maintainability-continuation-design.md
|
||||
│ │ │ ├── 2026-06-09-poke-without-message-design.md
|
||||
│ │ │ └── 2026-06-13-documentation-site-design.md
|
||||
│ │ └── plans/
|
||||
│ │ ├── _archived/
|
||||
│ │ │ ├── 2026-05-29-finish-dv-document-tree.md
|
||||
│ │ │ ├── 2026-05-29-dv-evidence-pack.md
|
||||
│ │ │ ├── 2026-05-29-swe2-swe3-baselines.md
|
||||
│ │ │ └── 2026-05-29-state-sync-ui-settings-validation.md
|
||||
│ │ ├── 2026-05-28-server-resolution-prefetch.md
|
||||
│ │ ├── 2026-05-28-chanora-server-prefetch-crate.md
|
||||
│ │ ├── 2026-06-06-chat-panel-switching.md
|
||||
│ │ ├── 2026-06-08-core-internal-split.md
|
||||
│ │ └── 2026-06-08-maintainability-continuation.md
|
||||
│ ├── offline-knowledge/
|
||||
│ │ ├── coverage-analysis.md
|
||||
│ │ ├── doc-quality-analysis.md
|
||||
│ │ ├── link-coverage-report.md
|
||||
│ │ └── reviews/
|
||||
│ ├── implementation-status-2026-05-28.md
|
||||
│ ├── release/ios-build.md
|
||||
│ └── impl-mapping.md
|
||||
├── apps/, crates/, core/
|
||||
├── AGENTS.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 5. Docusaurus Configuration
|
||||
|
||||
### 5.1 Site configuration (`docusaurus.config.js`)
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
title: 'Chanora Engineering Docs',
|
||||
tagline: 'ASPICE-compliant engineering documentation with automated traceability',
|
||||
url: 'https://docs.chanora.dev',
|
||||
baseUrl: '/',
|
||||
organizationName: 'chanoraapp',
|
||||
projectName: 'docs',
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
i18n: { defaultLocale: 'en', locales: ['en'] },
|
||||
themes: ['@docusaurus/theme-classic'],
|
||||
plugins: [
|
||||
'./plugins/traceability',
|
||||
],
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
title: 'Chanora Docs',
|
||||
items: [
|
||||
{ type: 'doc', position: 'left', label: 'Requirements', docId: 'requirements/sysrs' },
|
||||
{ type: 'doc', position: 'left', label: 'Architecture', docId: 'architecture/sad' },
|
||||
{ type: 'doc', position: 'left', label: 'Verification', docId: 'verification/verification-master-plan' },
|
||||
{ type: 'doc', position: 'left', label: 'Governance', docId: 'governance/document-index' },
|
||||
{ type: 'doc', position: 'left', label: 'Security', docId: 'security/security-privacy-legal-guideline' },
|
||||
{ type: 'doc', position: 'left', label: 'Release', docId: 'release/platform-release-policy' },
|
||||
{ type: 'doc', position: 'left', label: 'References', docId: 'references/external-references' },
|
||||
{ type: 'doc', position: 'left', label: 'UI/UX', docId: 'ui-ux/material3-guideline' },
|
||||
{ type: 'tags' },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{ title: 'Docs', items: [
|
||||
{ label: 'Requirements', to: '/docs/requirements/sysrs' },
|
||||
{ label: 'Architecture', to: '/docs/architecture/sad' },
|
||||
{ label: 'Verification', to: '/docs/verification/verification-master-plan' },
|
||||
]},
|
||||
{ title: 'Governance', items: [
|
||||
{ label: 'Traceability Matrix', to: '/docs/governance/traceability-matrix' },
|
||||
{ label: 'Decision Register', to: '/docs/governance/product-decision-register' },
|
||||
{ label: 'Document Index', to: '/docs/governance/document-index' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 Sidebar (`sidebars.js`)
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
requirements: [
|
||||
'requirements/sysrs',
|
||||
'requirements/sysdes',
|
||||
'requirements/srs',
|
||||
],
|
||||
architecture: [
|
||||
'architecture/sad',
|
||||
'architecture/sdd',
|
||||
'architecture/file-transfer-design',
|
||||
'architecture/file-transfer-research',
|
||||
'architecture/file-transfer-implementation-plan',
|
||||
'architecture/desktop-ptt-architecture',
|
||||
],
|
||||
verification: [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'System Level',
|
||||
items: ['verification/sys4-system-integration-verification-plan'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Software Integration',
|
||||
items: ['verification/swe5-software-integration-verification-plan'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Unit Level',
|
||||
items: ['verification/swe4-unit-verification-plan'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Software Qualification',
|
||||
items: ['verification/swe6-software-verification-plan'],
|
||||
},
|
||||
'verification/verification-master-plan',
|
||||
],
|
||||
governance: [
|
||||
'governance/document-index',
|
||||
'governance/traceability-matrix',
|
||||
'governance/product-decision-register',
|
||||
'governance/baseline-approval-record',
|
||||
'governance/baseline-candidate-validation-report',
|
||||
'governance/document-review-report',
|
||||
'governance/document-naming-convention',
|
||||
'governance/decision-impact-assessment',
|
||||
'governance/git-commit-message-convention',
|
||||
'governance/repo-format-validation-report',
|
||||
'governance/path-migration-map',
|
||||
'governance/maintainability-review-2026-06-08',
|
||||
],
|
||||
security: [
|
||||
'security/security-privacy-legal-guideline',
|
||||
'security/threat-model',
|
||||
'security/secure-storage-audit-report',
|
||||
'security/diagnostic-redaction-audit-report',
|
||||
'security/dependency-and-supply-chain-report',
|
||||
'security/license-inventory',
|
||||
'security/flutter-license-inventory',
|
||||
'privacy/privacy-policy',
|
||||
'legal/trademark-and-attribution-review',
|
||||
],
|
||||
release: [
|
||||
'release/platform-release-policy',
|
||||
'release/release-readiness-go-nogo-record',
|
||||
'release/dv-waiver-register',
|
||||
],
|
||||
references: [
|
||||
'references/external-references',
|
||||
'references/aspice-swe2-swe3-integration-note',
|
||||
'references/yatqa-en',
|
||||
'references/yatqa-de',
|
||||
'references/teaspeak-overview',
|
||||
'references/respeak-overview',
|
||||
],
|
||||
uiux: [
|
||||
'ui-ux/material3-guideline',
|
||||
'ui-ux/material3-design-tokens',
|
||||
'ui-ux/material3-component-catalog',
|
||||
'ui-ux/adaptive-layout-platform-guide',
|
||||
'i18n/localization-architecture',
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## 6. Tag-Based Traceability
|
||||
|
||||
### 6.1 Front matter schema
|
||||
|
||||
Every document includes YAML front matter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
tags: [swe.2, architecture, SRS-003, SRS-008, SRS-016]
|
||||
upstream: [srs, sysdes] # Custom metadata for traceability plugin
|
||||
downstream: [sdd, swe4, swe5] # Custom metadata for traceability plugin
|
||||
lifecycle: SWE.2 # Custom metadata for traceability plugin
|
||||
status: baseline # Custom metadata for traceability plugin
|
||||
---
|
||||
```
|
||||
|
||||
The `tags` field is consumed by the Docusaurus tags system for browsing. The `upstream`, `downstream`, `lifecycle`, and `status` fields are custom metadata consumed by the traceability plugin for chain validation.
|
||||
|
||||
### 6.2 Tag categories
|
||||
|
||||
| Tag pattern | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `swe.1` through `swe.6`, `sys.4` | ASPICE lifecycle stage | Every doc gets at least one |
|
||||
| `sysrs`, `sysdes`, `srs`, `sad`, `sdd` | Document type | Identifies the doc in the chain |
|
||||
| `requirements`, `architecture`, `verification`, `governance` | Section category | For filtering |
|
||||
| `SysRS-233`, `SRS-045`, `SDD-MOD-009` | Requirement/module IDs | Traceability links |
|
||||
| `baseline`, `draft`, `candidate` | Document status | Assessor visibility |
|
||||
| `dec-012`, `dec-020` | Decision register refs | Cross-ref to governance |
|
||||
|
||||
### 6.3 Section defaults
|
||||
|
||||
Docusaurus uses front matter `tags:` per document. Section-level defaults are not needed since each document carries its own tags.
|
||||
|
||||
### 6.4 Verification page trace mappings
|
||||
|
||||
| Verification plan | Upstream traces | Tags |
|
||||
|---|---|---|
|
||||
| SYS.4 System Integration | SysDes, SysRS | `[sys.4, verification, SysDes-102, SysDes-103, ...]` |
|
||||
| SWE.5 Software Integration | SAD (SWE.2) | `[swe.5, verification, sad-component-bridge, ...]` |
|
||||
| SWE.4 Unit Verification | SDD (SWE.3) | `[swe.4, verification, SDD-MOD-001, ...]` |
|
||||
| SWE.6 Software Verification | SRS | `[swe.6, verification, SRS-128, ...]` |
|
||||
|
||||
## 7. Custom Traceability Plugin
|
||||
|
||||
### 7.1 Location
|
||||
|
||||
`plugins/traceability/index.js` — Docusaurus plugin, ~100 lines JavaScript.
|
||||
|
||||
### 7.2 Behavior
|
||||
|
||||
On `on_page_markdown` event:
|
||||
- Scan each page for requirement ID patterns: `SysRS-\d+`, `SysDes-\d+`, `SRS-\d+`, `SDD-MOD-\d+`, `DEC-\d+`
|
||||
- Build an in-memory traceability graph: upstream ID → downstream document → verification plan
|
||||
|
||||
On `on_post_build` event:
|
||||
- Validate every requirement ID referenced downstream exists in its source document
|
||||
- Validate every upstream document ID has at least one downstream allocation
|
||||
- Flag orphaned references (IDs mentioned but never defined)
|
||||
- Verify bidirectional completeness
|
||||
|
||||
### 7.3 Outputs
|
||||
|
||||
- `traceability-coverage.json` — machine-readable coverage report with chain completeness percentages
|
||||
- Console output with pass/fail summary
|
||||
- Traceability dashboard page with coverage table and broken chain details
|
||||
- Build failure (`sys.exit(1)`) on broken chains when `strict: true`
|
||||
|
||||
### 7.4 Standalone CI validator
|
||||
|
||||
`scripts/validate-traceability.mjs` — same validation logic, runnable without Docusaurus build:
|
||||
|
||||
```
|
||||
python scripts/validate_traceability.py docs/
|
||||
```
|
||||
|
||||
Exit code 0 = all chains valid. Exit code 1 = broken chains with details on stderr.
|
||||
|
||||
## 8. Hosting & Deployment
|
||||
|
||||
### 8.1 Architecture
|
||||
|
||||
```
|
||||
chanora-docs repo → push to main → GitHub Actions
|
||||
→ validate-traceability.mjs
|
||||
→ npm run build
|
||||
→ Cloudflare Pages (via Wrangler)
|
||||
→ Cloudflare Access policy (email-based auth)
|
||||
```
|
||||
|
||||
### 8.2 CI workflow
|
||||
|
||||
On pull request: build + validate only (no deploy).
|
||||
On push to main: build + validate + deploy to Cloudflare Pages.
|
||||
|
||||
### 8.3 Cloudflare Access policy
|
||||
|
||||
- Free tier for up to 50 users
|
||||
- Email-based authentication with optional Google/GitHub SSO
|
||||
- One-time PIN for external assessors
|
||||
- Access rules: allow company emails, specific assessor emails; block all others
|
||||
|
||||
## 9. Migration Plan
|
||||
|
||||
### 9.1 Code path reference cleanup
|
||||
|
||||
SAD and SDD currently list file paths (`crates/chanora_protocol/src/`) in component tables. These references will be:
|
||||
- Replaced with component/module IDs only in the docs submodule
|
||||
- Preserved in `dev-docs/impl-mapping.md` in the code repo for developer convenience
|
||||
|
||||
### 9.2 File moves
|
||||
|
||||
| From (code repo) | To | Action |
|
||||
|---|---|---|
|
||||
| `docs/sysrs.md` | docs submodule | Move + add front matter |
|
||||
| `docs/sysdes.md` | docs submodule | Move + add front matter |
|
||||
| `docs/srs.md` | docs submodule | Move + add front matter |
|
||||
| `docs/requirements/*` | docs submodule | Move (path records) |
|
||||
| `docs/architecture/*` | docs submodule | Move + cleanup code paths |
|
||||
| `docs/verification/*` | docs submodule | Move + add front matter |
|
||||
| `docs/governance/*` | docs submodule | Move + add front matter |
|
||||
| `docs/security/*` | docs submodule | Move + add front matter |
|
||||
| `docs/privacy/*` | docs submodule | Move |
|
||||
| `docs/legal/*` | docs submodule | Move |
|
||||
| `docs/release/policy+go-nogo+waiver` | docs submodule | Move |
|
||||
| `docs/references/*` | docs submodule | Move |
|
||||
| `docs/ui-ux/*` | docs submodule | Move |
|
||||
| `docs/i18n/*` | docs submodule | Move |
|
||||
| `docs/material3-guideline.md` | docs submodule | Move |
|
||||
| `docs/offline-knowledge/external/*` | docs submodule `references/` (flattened) | Move + rename |
|
||||
| `docs/superpowers/*` | `dev-docs/superpowers/` | Move |
|
||||
| `docs/offline-knowledge/` (remaining) | `dev-docs/offline-knowledge/` | Move |
|
||||
| `docs/implementation-status-*` | `dev-docs/` | Move |
|
||||
| `docs/release/ios-build.md` | `dev-docs/release/` | Move |
|
||||
|
||||
### 9.3 Cross-reference updates
|
||||
|
||||
All backtick path references (`docs/srs.md`) should become markdown links (`[SRS](../srs.md)` or `[SRS](srs.md)`) for both GitHub and Docusaurus rendering.
|
||||
|
||||
### 9.4 Post-migration
|
||||
|
||||
- Remove `docs/` contents from code repo
|
||||
- Add `chanora-docs` as git submodule at `docs/`
|
||||
- Create `dev-docs/` directory with local-only files
|
||||
- Update README references to new paths
|
||||
- Write `AGENTS.md` with new conventions
|
||||
- Update `opencode.json` or `.opencode/` references
|
||||
|
||||
## 10. AGENTS.md
|
||||
|
||||
An `AGENTS.md` file will be written at the code repo root documenting:
|
||||
- The two-repo model (docs/ as submodule, dev-docs/ as local)
|
||||
- What content goes where
|
||||
- ASPICE traceability chain and rules
|
||||
- Code architecture overview
|
||||
- Verification commands
|
||||
- Agent working conventions (no edits in docs/ without submodule awareness)
|
||||
|
||||
## 11. Deferred Items
|
||||
|
||||
| Item | Reason | When |
|
||||
|---|---|---|
|
||||
| Document provenance records | Convert completed ASPICE plans into provenance evidence | Follow-up task |
|
||||
| Custom Docusaurus traceability plugin | Core feature, built during implementation | Phase 1 |
|
||||
| Cloudflare Pages + Access setup | Requires account creation, domain config | During deployment |
|
||||
| `impl-mapping.md` creation | Extract code paths from SAD/SDD during migration | During migration |
|
||||
-1
Submodule docs deleted from 64e38f7d53
@@ -0,0 +1,43 @@
|
||||
# Chanora Desktop Push-to-Talk Architecture
|
||||
|
||||
**Document status:** DV meeting baseline candidate
|
||||
**Date:** 2026-05-29
|
||||
**Related documents:** `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/release/dv-waiver-register.md`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document records the desktop push-to-talk architecture advertised by the README and connects it to the SWE.2/SWE.3 baselines.
|
||||
|
||||
## 2. Architecture Summary
|
||||
|
||||
Desktop PTT is implemented as a platform-capability feature. The application must detect the active backend, expose the resulting `PttCapabilityLevel`, and avoid claiming global PTT support when the runtime falls back to focused-input behavior.
|
||||
|
||||
## 3. Platform Backends
|
||||
|
||||
| Platform | Backend strategy | Release claim rule |
|
||||
|---|---|---|
|
||||
| Windows | Raw Input first, low-level keyboard hook fallback, focused fallback if unavailable | Claim only the detected runtime capability |
|
||||
| macOS | Event Tap where permission and OS policy allow; focused fallback otherwise | Claim Global PTT only with permission/backend evidence |
|
||||
| Linux | Freedesktop GlobalShortcuts portal where available; focused fallback otherwise | State portal/fallback behavior clearly |
|
||||
|
||||
## 4. Safety Rules
|
||||
|
||||
| Rule | Purpose |
|
||||
|---|---|
|
||||
| Missed-key-up watchdog clears transmit after timeout | Prevents stuck transmit when an OS suppresses key-up |
|
||||
| Capability is surfaced to UI and release record | Prevents over-claiming platform support |
|
||||
| Mouse side-button support is platform-dependent | Avoids blocking release on Linux portal limitations |
|
||||
| Focused fallback remains available | Preserves usable PTT when global backends are unavailable |
|
||||
|
||||
## 5. Verification Handoff
|
||||
|
||||
| Evidence | Required result |
|
||||
|---|---|
|
||||
| Per-platform smoke | Active backend and fallback behavior recorded |
|
||||
| UI inspection | PTT capability badge matches runtime backend |
|
||||
| Release readiness | Release notes mirror actual capability per platform |
|
||||
| Safety test | Watchdog prevents stuck transmit after missed key-up |
|
||||
|
||||
## 6. DV Conclusion
|
||||
|
||||
The desktop PTT architecture is documented for DV navigation. Public release claims still require per-platform PTT evidence attached to the release-readiness record.
|
||||
@@ -0,0 +1,737 @@
|
||||
# File Transfer Design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft for review
|
||||
**Scope:** Download files from TeamSpeak-compatible servers via the native client protocol, starting with avatars and icons.
|
||||
**Direct upstream source:** `docs/architecture/sad.md` (SAD-067, SDD-MOD-009)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Chanora needs to download files stored on TeamSpeak-compatible servers. The most visible use cases are client avatars and server/channel/client icons. The file transfer mechanism is also used for channel file browser features, but this document scopes the initial design to avatar and icon retrieval only.
|
||||
|
||||
This document describes:
|
||||
|
||||
- How the TeamSpeak file transfer protocol works.
|
||||
- How `tsclientlib` exposes it.
|
||||
- How Chanora should integrate it following the existing protocol adapter pattern.
|
||||
- How the result flows through the bridge to the Flutter UI layer.
|
||||
|
||||
Upload, channel file browsing, and file deletion are explicitly out of scope for the initial implementation.
|
||||
|
||||
## 2. Protocol Background
|
||||
|
||||
### 2.1 Two-Phase Transfer
|
||||
|
||||
TeamSpeak file transfer is a two-phase process:
|
||||
|
||||
1. **Command phase** — The client sends a command over the main encrypted UDP connection to request a transfer token (`ftkey`).
|
||||
2. **Transfer phase** — The client opens a separate TCP connection to the server's file transfer port (default `30033`) and sends the `ftkey` to authenticate the transfer. Raw bytes flow over this TCP stream.
|
||||
|
||||
### 2.2 Relevant ServerQuery Commands
|
||||
|
||||
| Command | Direction | Purpose |
|
||||
|---|---|---|
|
||||
| `ftinitdownload` | Client → Server | Initialize a download. Returns `ftkey`, `port`, `size`. |
|
||||
| `ftgetfileinfo` | Client → Server | Get metadata for one or more files. |
|
||||
| `ftgetfilelist` | Client → Server | List files in a channel's file repository. |
|
||||
| `ftinitupload` | Client → Server | Initialize an upload. |
|
||||
| `ftlist` | Client → Server | List active file transfers. |
|
||||
| `ftstop` | Client → Server | Stop a running transfer. |
|
||||
| `ftdeletefile` | Client → Server | Delete a file. |
|
||||
| `ftcreatedir` | Client → Server | Create a directory. |
|
||||
| `ftrenamefile` | Client → Server | Rename or move a file. |
|
||||
|
||||
Initial scope uses only `ftinitdownload` and `ftgetfileinfo`.
|
||||
|
||||
### 2.3 File Paths
|
||||
|
||||
Files are addressed by a path scoped to a channel ID (`cid`):
|
||||
|
||||
- `cid=0` — Server-level file repository. Avatars and icons live here.
|
||||
- `cid=N` (non-zero) — Channel-specific file repository.
|
||||
|
||||
Avatar path: `/avatar_<hex>` where `<hex>` is derived from the client's unique identifier (UID). Each byte of the base64-decoded UID is split into two nibbles, and each nibble maps to a letter `a` through `p` (0→a, 1→b, ..., 15→p).
|
||||
|
||||
Icon path: `/icon_<id>` where `<id>` is the icon's signed 64-bit integer ID. If negative, treat as unsigned for the path.
|
||||
|
||||
### 2.4 `ftinitdownload` Command
|
||||
|
||||
```
|
||||
ftinitdownload clientftfid={id} name={path} cid={channelId} cpw={password} seekpos={seek} proto=0
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `clientftfid` | `u16` | Arbitrary client-side transfer ID. |
|
||||
| `name` | `string` | File path, e.g. `/avatar_abcdef`. |
|
||||
| `cid` | `ChannelId` | Channel scope (0 = server). |
|
||||
| `cpw` | `string` | Channel password. Empty for server-level. |
|
||||
| `seekpos` | `u64` | Resume offset. 0 for a fresh download. |
|
||||
| `proto` | `u8` | Protocol version. Always 0. |
|
||||
|
||||
Server response:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `clientftfid` | `u16` | Echo of the client transfer ID. |
|
||||
| `serverftfid` | `u16` | Server-side transfer ID. |
|
||||
| `ftkey` | `string` | One-time transfer key (hex). |
|
||||
| `port` | `u16` | File transfer TCP port (usually 30033). |
|
||||
| `size` | `u64` | File size in bytes. |
|
||||
| `proto` | `u8` | Protocol version echo. |
|
||||
| `ip` | `string` (optional) | Override IP for the TCP connection. |
|
||||
|
||||
### 2.5 TCP Transfer
|
||||
|
||||
After receiving the `ftkey`, the client:
|
||||
|
||||
1. Opens a TCP connection to `server_ip:port`.
|
||||
2. Sends `ftkey` followed by a newline.
|
||||
3. Reads exactly `size` bytes of raw file data.
|
||||
4. Closes the TCP connection.
|
||||
|
||||
### 2.6 Permissions
|
||||
|
||||
File transfer requires the following permissions on the server:
|
||||
|
||||
| Permission | Needed for |
|
||||
|---|---|
|
||||
| `i_ft_file_download_power` | Downloading files. |
|
||||
| `i_ft_needed_file_download_power` | Required download power on the channel/server. |
|
||||
| `b_ft_ignore_password` | Bypassing channel passwords (not needed for avatars). |
|
||||
|
||||
Avatar downloads typically require only basic download power because avatars are in the server-level repository (`cid=0`), which is generally accessible.
|
||||
|
||||
### 2.7 Avatar Detection
|
||||
|
||||
When a client connects or updates, the server sends `client_flag_avatar` as a string (the avatar hash). If non-empty, the client has an avatar. The avatar is downloaded from `/avatar_<hex>` where `<hex>` is computed from the client's UID (not from the hash string itself — the hash is just a presence indicator).
|
||||
|
||||
## 3. tsclientlib Support
|
||||
|
||||
`tsclientlib` implements file transfer natively. The library handles the entire command + TCP flow internally:
|
||||
|
||||
### 3.1 Public API
|
||||
|
||||
```rust
|
||||
// tsclientlib/src/lib.rs (relevant signatures)
|
||||
impl Connection {
|
||||
pub fn download_file(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
path: &str,
|
||||
channel_password: Option<&str>,
|
||||
seek_position: Option<u64>,
|
||||
) -> Result<FiletransferHandle>;
|
||||
|
||||
pub fn upload_file(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
path: &str,
|
||||
channel_password: Option<&str>,
|
||||
size: u64,
|
||||
overwrite: bool,
|
||||
resume: bool,
|
||||
) -> Result<FiletransferHandle>;
|
||||
}
|
||||
```
|
||||
|
||||
`download_file` sends the `ftinitdownload` command and returns a `FiletransferHandle(u16)` immediately. The actual transfer completes asynchronously.
|
||||
|
||||
### 3.2 Stream Items
|
||||
|
||||
The connection's event stream emits:
|
||||
|
||||
| StreamItem | When | Data |
|
||||
|---|---|---|
|
||||
| `StreamItem::FileDownload(FileDownloadResult)` | Server responds with `ftkey`; TCP connected and `ftkey` written | `{ size: u64, stream: TcpStream }` |
|
||||
| `StreamItem::FileUpload(FileUploadResult)` | Upload ready | `{ seek_position: u64, stream: TcpStream }` |
|
||||
| `StreamItem::FiletransferFailed(FiletransferHandle, Error)` | Transfer failed | Handle + error |
|
||||
|
||||
When `FileDownload` fires, tsclientlib has already:
|
||||
|
||||
1. Sent `ftinitdownload` over the encrypted UDP command channel.
|
||||
2. Received the `ftkey`, `port`, and `size` from the server.
|
||||
3. Opened a TCP connection to `server:port`.
|
||||
4. Written the `ftkey` to the TCP socket.
|
||||
|
||||
The `TcpStream` in `FileDownloadResult` is ready to read; Chanora only needs to read exactly `size` bytes.
|
||||
|
||||
### 3.3 Avatar Helper
|
||||
|
||||
`tsproto-types` provides `Uid::as_avatar()` which computes the avatar filename from a UID. Chanora's existing `uid_to_avatar_path()` in `adapter.rs` does the same thing independently.
|
||||
|
||||
### 3.4 Doc-Comment Examples
|
||||
|
||||
tsclientlib's source contains usage examples in doc comments:
|
||||
|
||||
```rust
|
||||
/// Download an icon:
|
||||
/// con.download_file(ChannelId(0), &format!("/icon_{}", icon_id), None, None)
|
||||
|
||||
/// Upload an avatar:
|
||||
/// con.upload_file(ChannelId(0), "/avatar", None, data.len() as u64, true, false)
|
||||
```
|
||||
|
||||
## 4. Architecture Integration
|
||||
|
||||
### 4.1 Existing Pattern
|
||||
|
||||
The protocol adapter (`crates/chanora_protocol`) uses a single tokio task that owns the `tsclientlib::Connection`. All operations follow this pattern:
|
||||
|
||||
1. Define a `Request` enum variant with parameters and a `oneshot::Sender` for the reply.
|
||||
2. Send the request through the `mpsc` channel to the connection task.
|
||||
3. The connection task calls tsclientlib and resolves the oneshot.
|
||||
|
||||
File transfer fits this pattern exactly. The only difference is that the result arrives asynchronously via `StreamItem::FileDownload` rather than immediately from the command call.
|
||||
|
||||
### 4.2 Design
|
||||
|
||||
The file transfer integration adds:
|
||||
|
||||
1. **`Request` variants** for file download.
|
||||
2. **A pending-downloads map** (`HashMap<FiletransferHandle, DownloadContext>`) in the connection task, mirroring the existing `pending_moves` pattern.
|
||||
3. **`StreamItem::FileDownload` and `StreamItem::FiletransferFailed`** handling in the event loop.
|
||||
4. **New DTOs** for file transfer results.
|
||||
5. **Convenience methods** on `ProtocolClient` for avatar and icon downloads.
|
||||
|
||||
### 4.3 Layer Responsibilities
|
||||
|
||||
| Layer | Responsibility |
|
||||
|---|---|
|
||||
| `chanora_protocol` | Call `tsclientlib::download_file`, track pending transfers, read `TcpStream`, return bytes. No tsclientlib types leak. |
|
||||
| `chanora_core` | Orchestrate when to download (e.g., on profile fetch or on avatar cache miss). |
|
||||
| `chanora_bridge` | Expose typed `download_avatar` / `download_icon` commands to Flutter. |
|
||||
| Flutter UI | Call bridge, display with `Image.memory()`. Cache in memory/image cache. |
|
||||
|
||||
### 4.4 Error Mapping
|
||||
|
||||
File transfer errors map to the existing `ProtocolError` variants:
|
||||
|
||||
| tsclientlib error | ProtocolError |
|
||||
|---|---|
|
||||
| Permission denied (TS3 error code) | `ServerRejected { code, message }` |
|
||||
| File not found | `ServerRejected { code, message }` |
|
||||
| Network/TCP failure | `Backend(String)` |
|
||||
| Timeout | `Timeout` |
|
||||
| Connection lost mid-transfer | `Lost(String)` |
|
||||
|
||||
## 5. Detailed Design
|
||||
|
||||
### 5.1 New Types in `dto.rs`
|
||||
|
||||
```rust
|
||||
/// A downloaded file's raw content and metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DownloadedFile {
|
||||
/// Raw file bytes.
|
||||
pub data: Vec<u8>,
|
||||
/// The server path that was requested.
|
||||
pub path: String,
|
||||
/// Channel ID the file was downloaded from.
|
||||
pub channel_id: u64,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 New Request Variants in `adapter.rs`
|
||||
|
||||
```rust
|
||||
enum Request {
|
||||
// ... existing variants ...
|
||||
|
||||
/// Download a file from the server's file repository.
|
||||
DownloadFile {
|
||||
/// Channel ID. 0 for server-level (avatars, icons).
|
||||
channel_id: u64,
|
||||
/// File path, e.g. "/avatar_abcdef" or "/icon_12345".
|
||||
path: String,
|
||||
/// Channel password. None for server-level files.
|
||||
channel_password: Option<String>,
|
||||
/// Reply channel for the result.
|
||||
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Pending Downloads Map
|
||||
|
||||
```rust
|
||||
type PendingDownloads = HashMap<tsclientlib::FiletransferHandle, PendingDownload>;
|
||||
|
||||
struct PendingDownload {
|
||||
path: String,
|
||||
channel_id: u64,
|
||||
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Event Loop Handling
|
||||
|
||||
In the connection task's main loop, add handling for file transfer stream items:
|
||||
|
||||
```rust
|
||||
// In handle_non_audio_stream_item or in the main loop:
|
||||
StreamItem::FileDownload(result) => {
|
||||
// result: FileDownloadResult { size, stream }
|
||||
// Look up the handle in pending_downloads
|
||||
// Use tokio::io::AsyncReadExt::read_exact to read 'size' bytes
|
||||
// Resolve the oneshot with DownloadedFile
|
||||
}
|
||||
StreamItem::FiletransferFailed(handle, error) => {
|
||||
// Look up the handle in pending_downloads
|
||||
// Resolve the oneshot with ProtocolError::Backend
|
||||
}
|
||||
```
|
||||
|
||||
The TCP read from the `TcpStream` is an async operation. Since the connection task already runs in a tokio context, the read can be done inline. However, for large files this would block the main event loop. Two approaches:
|
||||
|
||||
**Option A: Read inline (simple, good for small files like avatars)**
|
||||
|
||||
Avatars are typically under 100 KB. Reading them inline in the event loop is acceptable and avoids complexity.
|
||||
|
||||
**Option B: Spawn a reader task**
|
||||
|
||||
For future channel-file-browser support with potentially large files, spawn a separate tokio task that reads the stream and sends the result back.
|
||||
|
||||
**Recommendation:** Start with Option A. The initial scope is avatars and icons (small files). Refactor to Option B when channel file browsing is implemented.
|
||||
|
||||
### 5.5 Request Handling
|
||||
|
||||
When the connection task receives `Request::DownloadFile`:
|
||||
|
||||
```rust
|
||||
Ok(Request::DownloadFile { channel_id, path, channel_password, reply }) => {
|
||||
let ts_channel_id = TsChannelId(channel_id);
|
||||
match con.download_file(ts_channel_id, &path, channel_password.as_deref(), None) {
|
||||
Ok(handle) => {
|
||||
pending_downloads.insert(handle, PendingDownload {
|
||||
path,
|
||||
channel_id,
|
||||
reply,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(ProtocolError::Backend(
|
||||
format!("download_file init: {e}")
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 Public API on `ProtocolClient`
|
||||
|
||||
```rust
|
||||
impl ProtocolClient {
|
||||
/// Download a file from the server's file repository.
|
||||
/// `channel_id` 0 means server-level (avatars, icons).
|
||||
pub async fn download_file(
|
||||
&self,
|
||||
channel_id: u64,
|
||||
path: String,
|
||||
channel_password: Option<String>,
|
||||
) -> Result<DownloadedFile, ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::DownloadFile { channel_id, path, channel_password, reply: tx })
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
/// Download a client's avatar image. Returns raw image bytes.
|
||||
/// Pass the `avatar_path` from `ClientProfile`.
|
||||
pub async fn download_avatar(
|
||||
&self,
|
||||
avatar_path: String,
|
||||
) -> Result<DownloadedFile, ProtocolError> {
|
||||
self.download_file(0, avatar_path, None).await
|
||||
}
|
||||
|
||||
/// Download a server, channel, or client icon by its icon ID.
|
||||
pub async fn download_icon(
|
||||
&self,
|
||||
icon_id: i64,
|
||||
) -> Result<DownloadedFile, ProtocolError> {
|
||||
let unsigned_id = icon_id as u64;
|
||||
let path = format!("/icon_{}", unsigned_id);
|
||||
self.download_file(0, path, None).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 Exports in `lib.rs`
|
||||
|
||||
```rust
|
||||
pub use dto::DownloadedFile;
|
||||
```
|
||||
|
||||
### 5.8 Bridge Layer
|
||||
|
||||
In `crates/chanora_bridge/src/api.rs`, add:
|
||||
|
||||
```rust
|
||||
pub async fn download_avatar(&self, avatar_path: String) -> Result<Vec<u8>, BridgeError> {
|
||||
self.protocol
|
||||
.download_avatar(avatar_path)
|
||||
.await
|
||||
.map(|file| file.data)
|
||||
.map_err(BridgeError::Protocol)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.9 Flutter Integration
|
||||
|
||||
Flutter side:
|
||||
|
||||
1. Call `clientProfile()` to get `ClientProfile` (already exists).
|
||||
2. Check if `avatarPath` is non-empty.
|
||||
3. Call bridge `downloadAvatar(avatarPath)` to get `Uint8List`.
|
||||
4. Display with `Image.memory(bytes)`.
|
||||
|
||||
Caching strategy:
|
||||
|
||||
- In-memory: Use Flutter's standard `ImageCache` or a simple `Map<String, Uint8List>` keyed by avatar path.
|
||||
- Disk: Consider caching to local storage for offline display. This is a follow-up decision, not MVP scope.
|
||||
- The avatar path already encodes the UID, so it can serve as a cache key.
|
||||
|
||||
## 6. Avatar Path Computation
|
||||
|
||||
Chanora already has this implemented in `adapter.rs`:
|
||||
|
||||
```rust
|
||||
fn uid_to_avatar_path(uid_b64: &str) -> String {
|
||||
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
|
||||
let mut rendered = String::with_capacity(decoded.len() * 2);
|
||||
for byte in decoded {
|
||||
rendered.push((b'a' + (byte >> 4)) as char);
|
||||
rendered.push((b'a' + (byte & 0x0f)) as char);
|
||||
}
|
||||
rendered
|
||||
}
|
||||
```
|
||||
|
||||
This maps each nibble to `a` through `p` (0→a, 1→b, ..., 15→p), matching the canonical TeamSpeak implementation.
|
||||
|
||||
The full avatar path is constructed as:
|
||||
|
||||
```rust
|
||||
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
|
||||
};
|
||||
```
|
||||
|
||||
This is already correct and used in `ClientProfile.avatar_path`. No changes needed.
|
||||
|
||||
## 7. Threading and Concurrency
|
||||
|
||||
| Concern | Design |
|
||||
|---|---|
|
||||
| TCP read blocking the event loop | For avatar/icon sizes (< 100 KB typically), inline async read is acceptable. Spawn a reader task for larger files when channel file browsing is added. |
|
||||
| Multiple concurrent downloads | `pending_downloads` is a HashMap keyed by `FiletransferHandle`. Multiple downloads can be in flight simultaneously. tsclientlib assigns unique handles. |
|
||||
| Download timeout | Add a deadline to pending downloads (e.g., 30 seconds). Sweep expired entries similar to the existing `pending_moves` sweep. |
|
||||
| Cancellation on disconnect | When the connection task exits, all pending oneshot senders are dropped, which resolves the caller's await with a `RecvError`. The caller maps this to `ProtocolError::Lost`. |
|
||||
|
||||
## 8. Diagnostic and Security Considerations
|
||||
|
||||
### 8.1 Diagnostic Redaction
|
||||
|
||||
- File transfer paths may contain user-identifying information (UID-derived avatar names). These should be registered for diagnostic redaction if they appear in log output.
|
||||
- File contents (avatar images) must not appear in log output or diagnostic exports.
|
||||
|
||||
### 8.2 Security
|
||||
|
||||
- The `ftkey` is a one-time token and must not be logged.
|
||||
- TCP file transfer connections are not encrypted. This is a TeamSpeak protocol limitation, not a Chanora design choice. Avatar data is public (visible to anyone on the server), so the risk is acceptable.
|
||||
- File download does not require secrets beyond the existing authenticated connection.
|
||||
|
||||
### 8.3 Privacy
|
||||
|
||||
- Avatar downloads reveal to the server that the user is viewing a specific client's avatar. This is inherent in the protocol.
|
||||
- Chanora should not download avatars proactively for all clients. Download only when the UI needs to display a specific avatar (lazy/on-demand).
|
||||
|
||||
## 9. Out of Scope
|
||||
|
||||
The following are explicitly deferred:
|
||||
|
||||
- File upload (avatar upload, channel file upload).
|
||||
- Channel file browser (listing, creating directories, deleting, renaming).
|
||||
- Resumable downloads (seek position > 0).
|
||||
- File transfer progress reporting.
|
||||
- myTeamSpeak avatar resolution (the `client_myteamspeak_avatar` field).
|
||||
- In-memory hot cache in Rust (Flutter's `ImageCache` handles decoded image caching; add Rust-side layer only if profiling shows need).
|
||||
- Upload, file browser, and channel file management.
|
||||
|
||||
## 10. Cache Architecture
|
||||
|
||||
### 10.1 Layer Ownership
|
||||
|
||||
| Layer | Responsibility | Storage |
|
||||
|---|---|---|
|
||||
| `chanora_protocol` | Download raw bytes from server. No caching logic. | None |
|
||||
| `chanora_cache` | Content-addressed blob store backed by `cacache`: crash-safe writes, SSRI integrity verification, key validation, eviction, clear. Separate crate from `chanora_storage`. | Platform cache directory |
|
||||
| `chanora_core` | Session-aware cache orchestration: check freshness, coalesce requests, rate-limit downloads, persist to disk via `chanora_cache`. | Delegates to `chanora_cache` |
|
||||
| `chanora_bridge` | Expose typed `download_avatar` / `clear_file_cache` / `file_cache_size` to Flutter. | None |
|
||||
| Flutter | Display via `Image.memory`. Standard `ImageCache` for hot memory caching. Evict from `ImageCache` when hash changes. | In-memory only |
|
||||
|
||||
### 10.2 Why Separate `chanora_cache` Crate
|
||||
|
||||
`chanora_cache` is a separate crate from `chanora_storage` for three reasons:
|
||||
|
||||
1. **Different durability semantics.** `chanora_storage` holds identity, bookmarks, and connection profiles — data the user explicitly created. `chanora_cache` holds downloaded blobs that are fully reconstructible from the server. Losing the cache is an inconvenience, not data loss.
|
||||
2. **Different backup semantics.** Cache should be excluded from backups; persistent storage should be included. Platform conventions (iOS `Library/Caches/` vs `Library/Application Support/`) reflect this distinction.
|
||||
3. **Different directory placement.** Cache lives in the platform's cache directory (OS may evict under storage pressure on mobile). Persistent storage lives in the support directory.
|
||||
|
||||
The cache wraps the `cacache` crate for production-tested crash safety and integrity verification. It does not share `chanora_storage`'s crate or directory, and does not reimplement cacache's atomic write or content-addressing logic.
|
||||
|
||||
### 10.3 Why Hybrid (Rust Disk + Flutter Memory)
|
||||
|
||||
- Flutter's built-in `ImageCache` is an LRU in-memory cache (default 1000 images / 100 MiB). It handles hot display caching automatically when you use `MemoryImage`.
|
||||
- Flutter has no built-in disk cache. `cached_network_image` / `flutter_cache_manager` are designed for HTTP URLs, not custom binary protocol data.
|
||||
- Rust already owns the protocol, the connection state, and the anti-flood budget. Putting disk cache here avoids a feedback loop across the bridge.
|
||||
|
||||
### 10.4 Cache Storage
|
||||
|
||||
`chanora_cache` wraps the `cacache` crate for its on-disk storage. The physical layout is managed by `cacache`:
|
||||
|
||||
```
|
||||
<app_cache_dir>/chanora/
|
||||
blobs/ ← cacache content store root
|
||||
content-v2/ ← content-addressed by SHA-512
|
||||
<sha512-hex>/
|
||||
data ← raw blob bytes
|
||||
tmp/ ← temp files (in-flight writes)
|
||||
index-v2/ ← entry index (key → content mapping)
|
||||
```
|
||||
|
||||
Chanora's `BlobCache` maps protocol keys to `cacache` string keys:
|
||||
|
||||
| Protocol key | cacache key | Example |
|
||||
|---|---|---|
|
||||
| Avatar MD5 | `"av_<md5hex>"` | `"av_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"` |
|
||||
| Icon CRC32 | `"ic_<crc32u>"` | `"ic_123456789"` |
|
||||
|
||||
**Why `cacache`:**
|
||||
|
||||
1. **Crash safety.** Production-tested atomic writes (temp → rename). Handles partial writes, power loss, crash mid-write. No custom crash safety code to maintain.
|
||||
2. **Integrity verification.** SSRI integrity check on every `read()`. Detects corruption, bit rot, partial writes automatically. Better than custom "delete on read failure".
|
||||
3. **Content dedup.** Same bytes stored once regardless of key. Same avatar on two servers = stored once automatically.
|
||||
4. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. Crash safety and integrity are the hard parts — `cacache` owns them.
|
||||
|
||||
**Why no `<server_uid>` subdirectory:** The protocol uses content-addressed identifiers. `client_flag_avatar` is the MD5 of the avatar bytes — a given avatar hash always maps to the same bytes regardless of which server the user is on. Same avatar on two servers = same content = stored once by `cacache`. This is a deliberate dedup advantage over per-server namespacing.
|
||||
|
||||
**Why no metadata sidecars:** Content is immutable (a given hash always maps to the same bytes). `cacache` manages its own entry index with timestamps. No custom metadata files needed.
|
||||
|
||||
**Key validation rules:**
|
||||
|
||||
| Prefix | Key format | Validation |
|
||||
|---|---|---|
|
||||
| `av_` | `av_<32 hex chars>` | MD5 is exactly 32 hex characters |
|
||||
| `ic_` | `ic_<1-10 digit number>` | CRC32 unsigned, 0–4294967295 |
|
||||
|
||||
Keys failing validation are rejected at the `BlobCache` API boundary. This prevents path traversal or malformed filenames on disk.
|
||||
|
||||
**Platform paths** (Flutter passes the base directory into Rust at startup, matching the existing `initStorage` pattern):
|
||||
|
||||
| Platform | Cache directory |
|
||||
|---|---|
|
||||
| Android | `context.cacheDir/chanora/` (via `getCacheDir()`) |
|
||||
| iOS | `Library/Caches/chanora/` (via `getApplicationCacheDirectory()`) |
|
||||
| macOS | `~/Library/Caches/chanora/` |
|
||||
| Windows | `%LOCALAPPDATA%/chanora/cache/` |
|
||||
| Linux | `$XDG_CACHE_HOME/chanora/` or `~/.cache/chanora/` |
|
||||
|
||||
Flutter already resolves platform-specific paths. The same `getApplicationCacheDirectory()` call that is available in `path_provider` across all Chanora target platforms should be used. This follows the existing pattern where `app_bootstrap.dart` calls `getApplicationSupportDirectory()` for persistent storage; avatar/icon cache uses the cache-equivalent directory instead.
|
||||
|
||||
The cache init call is separate from storage init:
|
||||
|
||||
```rust
|
||||
// Bridge init (Flutter calls these at startup)
|
||||
pub fn init_storage(support_dir: String) -> Result<(), BridgeError>; // existing
|
||||
pub fn init_cache(cache_dir: String) -> Result<(), BridgeError>; // new
|
||||
```
|
||||
|
||||
### 10.5 Cache Freshness Strategy
|
||||
|
||||
The `client_flag_avatar` field on each client is the authoritative freshness signal:
|
||||
|
||||
```
|
||||
On connect / on client list update:
|
||||
For each visible client with non-empty avatar_hash:
|
||||
cache_key = "av_<avatar_hash>"
|
||||
if cacache entry exists for "av_<avatar_hash>":
|
||||
use cached file (zero downloads)
|
||||
else:
|
||||
enqueue download for avatar_path with expected hash = avatar_hash
|
||||
|
||||
On avatar_hash change for a client:
|
||||
The new hash produces a different cacache key.
|
||||
The old entry remains until eviction or manual clear.
|
||||
The new file is downloaded on demand.
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
- **First connect:** No cache hits. Downloads happen lazily as the UI requests avatars.
|
||||
- **Reconnect to same server:** All avatars hit cache instantly (hashes match keys). Zero downloads.
|
||||
- **User changes avatar:** New hash = new cache key. Old entry becomes orphan. New file downloads on next UI request.
|
||||
- **Same user on different server:** Same avatar hash = same cached file. Cross-server dedup for free.
|
||||
|
||||
### 10.6 Anti-Flood and Download Timing
|
||||
|
||||
TeamSpeak servers enforce anti-flood rate limiting. Downloading all avatars eagerly on connect would trigger it on servers with many users.
|
||||
|
||||
**Strategy: lazy + throttled prefetch**
|
||||
|
||||
| Phase | What | Rate |
|
||||
|---|---|---|
|
||||
| Connect settle (first 2-5 s) | Do nothing. Let the initial state snapshot and channel tree arrive. | — |
|
||||
| After settle | UI requests avatars for visible clients in the current channel. These trigger downloads one at a time. | Max 1-2 concurrent downloads per server |
|
||||
| Channel switch | UI requests avatars for newly visible clients. | Same throttle |
|
||||
| Background prefetch (optional, future) | Low-priority downloads for clients in adjacent channels. | 1 request per 500 ms |
|
||||
|
||||
**Anti-flood handling:**
|
||||
|
||||
- If the server responds with an anti-flood error (TS3 error code `0x0701` = `client_could_not_be_banned` / flood-related), back off the download queue.
|
||||
- Implement a simple semaphore in `chanora_core`: max 1-2 concurrent downloads.
|
||||
- If a download gets a flood error, pause the queue for 5 seconds, then resume at reduced rate.
|
||||
|
||||
### 10.7 Retry on Failure
|
||||
|
||||
| Failure type | Strategy |
|
||||
|---|---|
|
||||
| Transient (network timeout, TCP reset) | Retry with exponential backoff: 5 s, 30 s, 2 min, 10 min. Cap at 10 min. |
|
||||
| Server flood limit hit | Pause queue 5 s, then resume at reduced rate. Do not count as a per-file retry. |
|
||||
| Permission denied (no download power) | Do not retry. Record negative cache entry. Only retry if hash changes. |
|
||||
| File not found (avatar removed) | Do not retry. Record negative cache entry. Clear when hash changes or becomes empty. |
|
||||
| Connection lost | All pending downloads fail. On reconnect, cache check runs fresh with current hashes. |
|
||||
|
||||
**Negative cache:** In-memory `HashMap<String, Instant>` with 5-minute TTL. Keys like `"av_<hash>"` or `"ic_<id>"` that received permanent errors are stored with an expiry. On lookup, expired entries are treated as absent. Cleared entirely on reconnect.
|
||||
|
||||
### 10.8 Request Coalescing
|
||||
|
||||
Multiple UI widgets may request the same avatar simultaneously (e.g., channel list + chat view + client info sheet).
|
||||
|
||||
**Pattern:** In `chanora_core`'s `FileTransferService`, maintain an in-flight map:
|
||||
|
||||
```rust
|
||||
HashMap<String, tokio::task::JoinHandle<Result<Vec<u8>, FileTransferError>>>
|
||||
```
|
||||
|
||||
- First request: start download, store handle.
|
||||
- Subsequent requests for same key: await the same handle.
|
||||
- When handle completes: write to cache, wake all waiters, remove from map.
|
||||
|
||||
### 10.9 Cache Eviction and Size Limits
|
||||
|
||||
**MVP approach:**
|
||||
|
||||
- No automatic size-based eviction in MVP. Avatars are small (typically 10-100 KB). Even 1000 avatars = ~50-100 MB.
|
||||
- Rely on platform cache directory semantics (OS may evict under storage pressure on mobile).
|
||||
- Old hash files accumulate but are harmless.
|
||||
|
||||
**Post-MVP:**
|
||||
|
||||
- `BlobCache::evict(max_bytes)` — walk `cacache::ls()` entries, sort by timestamp (oldest first), delete until total size < `max_bytes`. `cacache` manages timestamps internally. No metadata sidecars needed.
|
||||
- Or simpler: `BlobCache::evict_older_than(duration)` — delete entries with timestamp older than N days.
|
||||
- Call on startup and periodically (e.g., every 24 hours or on app resume).
|
||||
|
||||
### 10.10 User-Initiated Cache Clear
|
||||
|
||||
Add a bridge method:
|
||||
|
||||
```rust
|
||||
pub fn clear_file_cache(&self) -> Result<(), BridgeError> {
|
||||
// Delete the entire blobs/ directory contents
|
||||
// Flutter evicts all avatar/icon-related entries from ImageCache
|
||||
}
|
||||
|
||||
pub fn file_cache_size(&self) -> Result<u64, BridgeError> {
|
||||
// Walk blobs/ and sum file sizes
|
||||
}
|
||||
```
|
||||
|
||||
Flutter side:
|
||||
|
||||
```dart
|
||||
// In settings or storage management UI:
|
||||
onPressed: () async {
|
||||
await api.clearFileCache();
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
}
|
||||
```
|
||||
|
||||
This should be exposed in the app's settings UI under a "Clear cache" or "Storage management" section.
|
||||
|
||||
### 10.11 Storage Clear Across Servers
|
||||
|
||||
Since the cache is flat with content-addressed keys (no server namespacing):
|
||||
|
||||
- Connecting to a different server does not conflict — same avatar hash = same file.
|
||||
- Avatars unique to the old server remain cached. If a user on the new server has the same avatar (same hash), it hits cache instantly (cross-server dedup).
|
||||
- Cache clear removes all cached data regardless of which server it came from.
|
||||
|
||||
### 10.12 Flutter Display Strategy
|
||||
|
||||
**Option A: Bytes across bridge (simpler, recommended for MVP)**
|
||||
|
||||
Rust returns `Vec<u8>` across the bridge. Flutter uses `Image.memory(bytes)`.
|
||||
|
||||
```dart
|
||||
final bytes = await api.downloadAvatar(clientUid: uid);
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(Uint8List.fromList(bytes));
|
||||
} else {
|
||||
return CircleAvatar(child: Text(initials)); // fallback
|
||||
}
|
||||
```
|
||||
|
||||
Flutter's `ImageCache` caches the decoded image in memory automatically. Same avatar bytes = cache hit in memory.
|
||||
|
||||
**Option B: File path across bridge (better for large images, future)**
|
||||
|
||||
Rust writes to disk and returns the file path. Flutter uses `FileImage`.
|
||||
|
||||
```dart
|
||||
final path = await api.getAvatarPath(avatarHash: hash);
|
||||
if (path != null) {
|
||||
return Image.file(File(path));
|
||||
} else {
|
||||
return CircleAvatar(child: Text(initials));
|
||||
}
|
||||
```
|
||||
|
||||
`FileImage` does not watch for file changes. When the hash changes, the UI must evict the old entry from `ImageCache` using `PaintingBinding.instance.imageCache.evict(key)`.
|
||||
|
||||
**Recommendation:** Start with Option A for MVP. It avoids file-path cross-platform complications and works well for small avatar files. The bridge already returns `Vec<u8>` for the download result.
|
||||
|
||||
## 11. Implementation Sequence
|
||||
|
||||
| Phase | Scope | What |
|
||||
|---|---|---|
|
||||
| Phase 1 | Protocol download | `Request::DownloadFile`, `StreamItem::FileDownload` handling, `ProtocolClient::download_avatar()` / `download_icon()`. No caching. |
|
||||
| Phase 2 | Bridge + Flutter display | Bridge `downloadAvatar()`, Flutter `Image.memory()`, initials fallback. Still no caching — every view re-downloads. |
|
||||
| Phase 3 | Rust disk cache | New `chanora_cache` crate: `cacache`-backed content-addressed blob store (`BlobCache`), key validation, mtime-based eviction, `init_cache` bridge call. |
|
||||
| Phase 4 | Session orchestration | `chanora_core` `FileTransferService`: request coalescing, rate limiter (semaphore), negative cache (5 min TTL), retry backoff. |
|
||||
| Phase 5 | Cache management | Bridge `clearFileCache()` + `fileCacheSize()`, Flutter settings UI, eviction on startup. |
|
||||
|
||||
Phase 1 and 2 deliver visible value (avatars in the UI). Phase 3-5 add robustness.
|
||||
|
||||
## 12. References
|
||||
|
||||
| Reference | Use |
|
||||
|---|---|
|
||||
| `ReSpeak/tsdeclarations` `Messages.toml` lines 828-830 | `ftinitdownload` command declaration |
|
||||
| `ReSpeak/tsdeclarations` `Messages.toml` lines 590 | `FileDownload` response structure |
|
||||
| `ReSpeak/tsdeclarations` `ts3protocol.md` | Low-level TeamSpeak protocol specification |
|
||||
| `ReSpeak/tsclientlib` `src/lib.rs` lines 956-1005 | `download_file` / `upload_file` public API |
|
||||
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1371-1427 | `StreamItem::FileDownload` handling |
|
||||
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1630-1672 | Outgoing init commands |
|
||||
| `Multivit4min/TS3-NodeJS-Library` `src/transport/FileTransfer.ts` | Reference TCP transfer implementation |
|
||||
| `Speckmops/ts3admin.class` `lib/ts3admin.class.php` lines 1352-1370 | Reference avatar download flow |
|
||||
| `docs/architecture/sad.md` SAD-067, SDD-MOD-009 | Protocol adapter boundary rules |
|
||||
| `crates/chanora_protocol/src/adapter.rs` lines 1561-1568 | Existing `uid_to_avatar_path` implementation |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,770 @@
|
||||
# File Transfer Cache Research
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Research complete, design implications noted (updated with TeaSpeak server findings)
|
||||
**Companion to:** `docs/architecture/file-transfer-design.md`
|
||||
**Purpose:** Factual findings from protocol analysis, existing client implementations, and cross-platform research that inform the cache architecture decision.
|
||||
|
||||
---
|
||||
|
||||
## 1. TS3 Protocol Identity Semantics
|
||||
|
||||
### 1.1 Avatar Identity
|
||||
|
||||
| Aspect | Value |
|
||||
|---|---|
|
||||
| Protocol field | `client_flag_avatar` |
|
||||
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:200`) |
|
||||
| Meaning | MD5 hash of the avatar file bytes |
|
||||
| Scope | Per-client per-server — a user can have different avatars on different servers |
|
||||
| Freshness | Automatically up-to-date for any client "in view" (`FLAG_CLIENT_VIEW`) |
|
||||
| Empty value | No avatar set |
|
||||
|
||||
**Key fact:** Identical avatar image bytes produce the **same** `client_flag_avatar` hash on any TS3 server. The hash is a content fingerprint, not a server-assigned identifier.
|
||||
|
||||
**Download path on server:** `/avatar_<base64HashClientUID>` — the filename is derived from the client's unique identifier (UID), not from the content hash. The content hash is communicated separately via `client_flag_avatar`.
|
||||
|
||||
**Sources:**
|
||||
- TeaSpeakLibrary `PropertyDefinition.h:200`: `PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "", TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}`
|
||||
- TS3AudioBot avatar upload: computes MD5 of image bytes, then sets `client_flag_avatar` to that hash ([`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341))
|
||||
- TS3 NodeJS Library: avatar filename is `avatar_${clientBase64HashClientUID}` ([`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313))
|
||||
- TS3 PHP Framework: avatar name derivation from UID ([`ts3phpframework/src/Node/Client.php:288-307`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Client.php#L288-L307))
|
||||
|
||||
### 1.2 Icon Identity
|
||||
|
||||
| Aspect | Value |
|
||||
|---|---|
|
||||
| Protocol fields | `channel_icon_id`, `client_icon_id`, `virtualserver_icon_id` |
|
||||
| Type | `TYPE_UNSIGNED_NUMBER` (TeaSpeakLibrary `PropertyDefinition.h:83,146,217`) |
|
||||
| Meaning | CRC32 (unsigned) of the icon file bytes |
|
||||
| Scope | Per-entity per-server — but CRC32 is content-derived |
|
||||
| Download path | `/icon_<unsigned_crc32>` |
|
||||
|
||||
**Key fact:** Identical icon bytes produce the **same** CRC32 on any TS3 server. The icon ID is a content fingerprint. The upload process computes `crc32.unsigned(data)` and stores at `/icon_<id>`.
|
||||
|
||||
**CRC32 collision caveat:** CRC32 is only 32 bits. Different icon content can theoretically produce the same CRC32. Qint's `filecache.rs` explicitly notes this: "there could be collisions because only CRC-32 is used." ForChanora's purposes (small icons, not security-critical), this is acceptable.
|
||||
|
||||
**Sources:**
|
||||
- TeaSpeakLibrary `PropertyDefinition.h:83,146,217`: all icon IDs are `TYPE_UNSIGNED_NUMBER`
|
||||
- TS3 NodeJS Library `uploadIcon()`: computes `crc32.unsigned(data)`, uploads to `/icon_<id>` ([`TS3-NodeJS-Library/src/TeamSpeak.ts:2234-2241`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/TeamSpeak.ts#L2234-L2241))
|
||||
- TS3 PHP Framework: icon path uses `/icon_<unsigned id>` ([`ts3phpframework/src/Node/Node.php:137-146`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Node.php#L137-L146))
|
||||
- TS3 community forum: "The filename itself is the result of the CRC32 checksum" (TeamSpeak staff)
|
||||
|
||||
### 1.3 Implication for Cache Design
|
||||
|
||||
Both avatars and icons are **content-addressed by the protocol itself**:
|
||||
|
||||
| Asset | Content hash source | Same content across servers? |
|
||||
|---|---|---|
|
||||
| Avatar | `client_flag_avatar` = MD5 of bytes | Same bytes → same hash → same ID |
|
||||
| Icon | `icon_id` = CRC32 of bytes | Same bytes → same CRC32 → same ID |
|
||||
|
||||
This means a **flat content-addressed blob store** can achieve zero-duplication without any per-server directories, hardlinks, or ref-counting.
|
||||
|
||||
---
|
||||
|
||||
## 2. Virtual Server Identity
|
||||
|
||||
### 2.1 Server UID
|
||||
|
||||
| Aspect | Value |
|
||||
|---|---|
|
||||
| Protocol field | `virtualserver_unique_identifier` |
|
||||
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:22`) |
|
||||
| Generated by | The server instance, on creation |
|
||||
| Globally unique? | **Not guaranteed** — locally generated, no central registry |
|
||||
| Stable? | Yes — persists across restarts of the same virtual server |
|
||||
|
||||
**Key fact:** `virtualserver_unique_identifier` is generated by each TS3 server. Two physically different servers could theoretically produce the same UID. It is **not safe as a global cache key**.
|
||||
|
||||
### 2.2 What Chanora Currently Tracks
|
||||
|
||||
| Layer | Server identity fields | Source |
|
||||
|---|---|---|
|
||||
| Protocol adapter (`adapter.rs:1752-1755`) | `server_name`, `welcome_message`, `platform`, `version` | `state.server.*` from tsclientlib |
|
||||
| DTO (`ServerSnapshot`) | `server_name`, `welcome_message`, `platform`, `version` | No UID field |
|
||||
| Bridge (`BridgeSnapshot`) | Same as DTO | Same |
|
||||
| Storage (bookmarks) | Keyed by `host` (hostname:port) | SQLite `WHERE host = ?1` |
|
||||
| Core (recent servers) | `cfg.address` as host | Auto-saved on connect |
|
||||
|
||||
**Chanora does not currently plumb `virtualserver_unique_identifier` through the DTO stack.** The field exists in tsclientlib's state but is not extracted.
|
||||
|
||||
### 2.3 Implication for Cache Design
|
||||
|
||||
Using `virtualserver_unique_identifier` as the sole cache key is risky (not globally unique). Using connection address (`host:port`) is safe but duplicates cache entries when the same server is accessed via different addresses.
|
||||
|
||||
**Recommendation:** For a content-addressed blob store, server identity is only needed for per-server metadata (eviction, "clear cache for this server"), not for the blob key itself. The blob key is the content hash.
|
||||
|
||||
---
|
||||
|
||||
## 3. Existing TS3 Client Cache Implementations
|
||||
|
||||
### 3.1 Qint (tsclientlib-based, Tauri + Rust)
|
||||
|
||||
**Architecture:** Per-server directory with SQLite metadata.
|
||||
|
||||
```
|
||||
<cache>/files/<server-uid>/<channel-id>/<base64(path)>
|
||||
```
|
||||
|
||||
**Avatar handling:**
|
||||
- Avatar state stored per `(server, client)` row in SQLite
|
||||
- On avatar hash change, deletes the cached `/avatar_<uid>` file for that server
|
||||
- Avatar download path: `/avatar_<uid_base64>`
|
||||
|
||||
**Icon handling:**
|
||||
- Icons path-cached with CRC32
|
||||
- Code comments note CRC32 collisions and freshness by mtime
|
||||
- Qint explicitly deletes and re-downloads when icon mtime changes
|
||||
|
||||
**Dedup:** None. Same avatar on 5 servers = 5 stored copies.
|
||||
|
||||
**Sources:**
|
||||
- [`Qint/proxy/src/filecache.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/filecache.rs#L1-L6): "Stores files transferred via the TS3 file transfer protocol. This includes icons and avatars."
|
||||
- [`Qint/proxy/src/db/mod.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/db/mod.rs#L1158-L1176): avatar hash change triggers delete
|
||||
- [`Qint/src-tauri/src/cmd.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/src-tauri/src/cmd.rs#L524-L539): file download command
|
||||
|
||||
### 3.2 TS3 Official Client (closed source)
|
||||
|
||||
**Architecture:** Lazy cache with SDK callbacks.
|
||||
|
||||
- `getAvatar()` returns cached path if present; otherwise triggers download
|
||||
- `onAvatarUpdated` callback fires when avatar is downloaded or deleted
|
||||
- Cache paths (from community documentation):
|
||||
- Windows: `%LOCALAPPDATA%\TeamSpeak\Cache\Default`
|
||||
- Linux: `~/.cache/TeamSpeak/Default`
|
||||
- macOS: `~/Library/Caches/TeamSpeak/Default`
|
||||
- SDK also exposes `CLIENT_MYTS_AVATAR` / `client_myteamspeak_avatar` for cross-server myTeamSpeak avatars
|
||||
|
||||
**Sources:**
|
||||
- [`ts3client-pluginsdk/src/plugin.c`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/src/plugin.c#L384-L396): `getAvatar()` and `onAvatarUpdated`
|
||||
- [`ts3client-pluginsdk/public_rare_definitions.h`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/include/teamspeak/public_rare_definitions.h#L284-L313): `CLIENT_FLAG_AVATAR`, `CLIENT_MYTS_AVATAR`
|
||||
- Community: [clear cache](https://community.teamspeak.com/t/clear-cache/41511), [broken icons](https://community.teamspeak.com/t/server-icons-are-displaying-a-broken-image-issues-with-local-cache/58680)
|
||||
|
||||
### 3.3 TeaSpeak Client (TypeScript + C++ native)
|
||||
|
||||
**Architecture:** Browser Cache API for images, per-server own-avatar storage.
|
||||
|
||||
**Key components (from `.d.ts` type declarations):**
|
||||
|
||||
- `AvatarManager` — per-connection (`FileManager`) avatar handler
|
||||
- `cachedAvatars` (private) — in-memory cache of `ClientAvatar` objects
|
||||
- `updateCache(clientAvatarId, clientAvatarHash)` — updates cache when hash changes
|
||||
- `resolveAvatar(clientAvatarId, avatarHash?, cacheOnly?)` — resolves avatar by ID
|
||||
- `flush_cache()` — clears cache
|
||||
- `create_avatar_download(client_avatar_id)` — initiates file transfer
|
||||
|
||||
- `ClientAvatar` — tracks individual avatar state
|
||||
- `clientAvatarId` — derived from client UID via `uniqueId2AvatarId()`
|
||||
- `currentAvatarHash` — the `client_flag_avatar` value
|
||||
- State machine: `unset` → `loading` → `loaded` / `errored`
|
||||
- `loadingTimestamp` — when download started
|
||||
|
||||
- `ImageCache` — generic image cache using browser Cache API
|
||||
- `resolveCached(key, maxAge?)` — check if cached
|
||||
- `putCache(key, value, type?, headers?)` — store
|
||||
- `cleanup(maxAge)` — evict old entries
|
||||
- `reset()` — clear all
|
||||
- `isPersistent()` — whether cache persists to disk
|
||||
|
||||
- `OwnAvatarStorage` — user's own avatar, keyed by `serverUniqueId + mode`
|
||||
- `loadAvatarImage(serverUniqueId, mode)` — load own avatar for a server
|
||||
- `updateAvatar(serverUniqueId, mode, target)` — update own avatar
|
||||
- `avatarUploadSucceeded(serverUniqueId)` — move from "uploading" to "server" state
|
||||
- Stores `LocalAvatarInfo`: fileName, fileSize, **fileHashMD5**, timestamps, contentType
|
||||
|
||||
- `FileManager` — per-connection file transfer manager
|
||||
- `MAX_CONCURRENT_TRANSFERS` — transfer concurrency limit
|
||||
- `avatars: AvatarManager` — avatar subsystem
|
||||
- `initializeFileDownload(options)` — start download (path, name, channel, target)
|
||||
- `deleteIcon(iconId: number)` — delete icon by ID
|
||||
|
||||
- `FileTransfer` — transfer state machine
|
||||
- States: `PENDING → INITIALIZING → CONNECTING → RUNNING → FINISHED / ERRORED / CANCELED`
|
||||
- `InitializedTransferProperties`: serverTransferId, transferKey, **addresses[]**, protocol, seekOffset, fileSize
|
||||
- Multiple addresses returned by server for file transfer (failover)
|
||||
|
||||
- `localIconCache: ImageCache` — global icon cache (singleton)
|
||||
|
||||
**Sources:**
|
||||
- TeaSpeak-Client `imports/shared-app/file/Avatars.d.ts` — ClientAvatar, AbstractAvatarManager
|
||||
- TeaSpeak-Client `imports/shared-app/file/LocalAvatars.d.ts` — AvatarManager
|
||||
- TeaSpeak-Client `imports/shared-app/file/LocalIcons.d.ts` — localIconCache
|
||||
- TeaSpeak-Client `imports/shared-app/file/ImageCache.d.ts` — ImageCache (browser Cache API)
|
||||
- TeaSpeak-Client `imports/shared-app/file/FileManager.d.ts` — FileManager, transfer API
|
||||
- TeaSpeak-Client `imports/shared-app/file/Transfer.d.ts` — FileTransfer, state machine, error types
|
||||
- TeaSpeak-Client `imports/shared-app/file/OwnAvatarStorage.d.ts` — own avatar per-server storage
|
||||
- TeaSpeak-Client `native/serverconnection/test/js/ft.ts` — file transfer test (TCP + ftkey protocol)
|
||||
|
||||
### 3.4 TS3AudioBot (C#)
|
||||
|
||||
**Architecture:** No local avatar cache. Avatar upload is hash-driven.
|
||||
|
||||
- Uploads avatar bytes to `/avatar`, computes MD5, sets `client_flag_avatar` to that hash
|
||||
- Bot avatar selection reads local files from an `avatars/` directory
|
||||
- No caching of other users' avatars
|
||||
|
||||
**Sources:**
|
||||
- [`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341)
|
||||
- [`TS3AudioBot/Bot.cs:420-470`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TS3AudioBot/Bot.cs#L420-L470)
|
||||
|
||||
### 3.5 TS3 NodeJS Library
|
||||
|
||||
**Architecture:** No local cache. Downloads on demand.
|
||||
|
||||
- Avatar filename: `avatar_${clientBase64HashClientUID}`
|
||||
- `getAvatar()` downloads directly — no caching layer
|
||||
- Tests assert the exact `/avatar_<base64uid>` path
|
||||
|
||||
**Sources:**
|
||||
- [`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313)
|
||||
- [`TS3-NodeJS-Library/tests/Client.spec.ts:342-358`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/tests/Client.spec.ts#L342-L358)
|
||||
|
||||
### 3.6 Summary Table
|
||||
|
||||
| Client | Cache Key Strategy | Dedup Across Servers? | Icon Cache |
|
||||
|---|---|---|---|
|
||||
| **Qint** | `<server-uid>/<channel-id>/<path>` | No | Yes (CRC32, mtime freshness) |
|
||||
| **TS3 Official** | Lazy cache (path-based) | Unknown | Yes |
|
||||
| **TeaSpeak** | UID-derived avatar ID + browser Cache API | Implicit (same hash = same cache) | Yes (global ImageCache) |
|
||||
| **TS3AudioBot** | None | N/A | No |
|
||||
| **TS3 NodeLib** | None | N/A | No |
|
||||
| **Chanora (decided)** | Content hash (MD5/CRC32), flat `blobs/` in `chanora_cache` crate | **Yes** | Yes |
|
||||
|
||||
---
|
||||
|
||||
## 4. TeaSpeak Protocol Definitions (Authoritative)
|
||||
|
||||
From TeaSpeakLibrary `src/PropertyDefinition.h` — the most complete open-source reference for TS3 protocol property types:
|
||||
|
||||
### 4.1 Avatar Properties
|
||||
|
||||
```cpp
|
||||
// Line 200
|
||||
PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "",
|
||||
TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}
|
||||
// "automatically up-to-date for any manager 'in view', this manager got an avatar"
|
||||
```
|
||||
|
||||
### 4.2 Icon Properties
|
||||
|
||||
```cpp
|
||||
// Line 83 — server icon
|
||||
PropertyDescription{VIRTUALSERVER_ICON_ID, "virtualserver_icon_id", "0",
|
||||
TYPE_UNSIGNED_NUMBER, FLAG_SERVER_VVSS | FLAG_USER_EDITABLE}
|
||||
|
||||
// Line 146 — channel icon
|
||||
PropertyDescription{CHANNEL_ICON_ID, "channel_icon_id", "0",
|
||||
TYPE_UNSIGNED_NUMBER, FLAG_CHANNEL_VIEW | FLAG_SS | FLAG_USER_EDITABLE}
|
||||
|
||||
// Line 217 — client icon
|
||||
PropertyDescription{CLIENT_ICON_ID, "client_icon_id", "0",
|
||||
TYPE_UNSIGNED_NUMBER, FLAG_CLIENT_VIEW | FLAG_CLIENT_VARIABLE}
|
||||
```
|
||||
|
||||
### 4.3 Server Identity
|
||||
|
||||
```cpp
|
||||
// Line 22
|
||||
PropertyDescription{VIRTUALSERVER_UNIQUE_IDENTIFIER,
|
||||
"virtualserver_unique_identifier", "",
|
||||
TYPE_STRING, FLAG_SERVER_VV | FLAG_SNAPSHOT}
|
||||
```
|
||||
|
||||
### 4.4 File Transfer Permissions
|
||||
|
||||
```cpp
|
||||
// From PermissionManager.cpp
|
||||
PermissionType::i_client_max_avatar_filesize // "Max avatar filesize in bytes"
|
||||
PermissionType::b_client_avatar_delete_other // "Allow deletion of avatars from other clients"
|
||||
PermissionType::b_ft_transfer_list // "Retrieve list of running filetransfers"
|
||||
```
|
||||
|
||||
### 4.5 File Transfer Error Codes
|
||||
|
||||
```cpp
|
||||
// From Error.h
|
||||
channel_no_filetransfer_supported = 0x30C
|
||||
file_transfer_connection_timeout = 0x80E
|
||||
file_transfer_complete = 0x811
|
||||
file_transfer_canceled = 0x812
|
||||
file_transfer_interrupted = 0x813
|
||||
file_transfer_server_quota_exceeded = 0x814
|
||||
file_transfer_client_quota_exceeded = 0x815
|
||||
file_transfer_reset = 0x816
|
||||
file_transfer_limit_reached = 0x817
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Platform Filesystem Research
|
||||
|
||||
### 5.1 Hardlink Support
|
||||
|
||||
| Platform | Filesystem | Hardlinks in App-Private Storage? | Gotcha |
|
||||
|---|---|---|---|
|
||||
| Android (API 28+) | ext4 / f2fs | **Yes** | Rust uses `libc::link`; not FUSE-mounted; same-filesystem only |
|
||||
| iOS | APFS | **Yes** (writable sandbox dirs) | App bundle is read-only; avoid hardlinks to bundle assets |
|
||||
| macOS | APFS | **Yes** | — |
|
||||
| Linux | ext4 / btrfs / xfs | **Yes** | — |
|
||||
| Windows | NTFS | **Yes** | Rust uses `CreateHardLinkW` |
|
||||
|
||||
**`std::fs::hard_link` gotchas (all platforms):**
|
||||
- Same filesystem required
|
||||
- Destination must not exist (returns error)
|
||||
- Symlink behavior is platform-specific
|
||||
- All hardlinks share the same inode — modifying one modifies all
|
||||
- Files must be treated as **immutable** for hardlink safety
|
||||
|
||||
**Sources:**
|
||||
- Rust stdlib: `hard_link` maps to `libc::link` (Unix), `CreateHardLinkW` (Windows) ([Rust source](https://github.com/rust-lang/rust/blob/beae781308e9ddef13074a03faf57ca2fac59a5b/library/std/src/fs.rs#L2898-L2900))
|
||||
- Android: internal storage uses ext4/f2fs, not FUSE ([Android scoped storage docs](https://source.android.com/docs/core/storage/scoped))
|
||||
- iOS: APFS supports hardlinks; writable sandbox directories work ([Apple FileSystem basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html))
|
||||
|
||||
### 5.2 Rust Cache Libraries
|
||||
|
||||
**`cacache`** (MIT licensed, production-ready):
|
||||
- Content-addressed disk cache
|
||||
- Automatic dedup, atomic writes, integrity verification
|
||||
- Exposes `hard_link`, `copy`, and `reflink` paths for retrieval
|
||||
- On-disk layout: `content-v2/sha512/...`
|
||||
- Could replace a custom implementation, but adds a dependency
|
||||
|
||||
**Sources:**
|
||||
- [`cacache-rs` README](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/README.md#L39-L60)
|
||||
- [`cacache-rs` content path](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/path.rs#L6-L19)
|
||||
- [`cacache-rs` hard_link impl](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/read.rs#L257-L285)
|
||||
|
||||
### 5.3 Flutter Cache Patterns
|
||||
|
||||
Common Flutter packages use **cache-dir + metadata DB**, not hardlink dedup:
|
||||
- `flutter_cache_manager`: files in cache dir + `sqflite` metadata
|
||||
- `super_cache_disk`: file-per-entry (`.dat` + `.meta`) in app cache dir
|
||||
|
||||
**Sources:**
|
||||
- [flutter_cache_manager on pub.dev](https://pub.dev/packages/flutter_cache_manager)
|
||||
- [super_cache_disk on pub.dev](https://pub.dev/packages/super_cache_disk/versions/1.0.0)
|
||||
|
||||
---
|
||||
|
||||
## 6. Chanora Codebase Context
|
||||
|
||||
### 6.1 Storage Patterns
|
||||
|
||||
| Component | Pattern | Location |
|
||||
|---|---|---|
|
||||
| Identity storage | Atomic write (temp + `sync_all` + `rename`), mode 0600 on Unix | `chanora_storage/src/lib.rs:446-476` |
|
||||
| Metadata | Same atomic write pattern | `chanora_storage/src/lib.rs:480-515` |
|
||||
| Bookmarks | SQLite at `<storage_dir>/chanora.db`, keyed by `host` | `chanora_storage/src/lib.rs:782-928` |
|
||||
| Storage root | `getApplicationSupportDirectory()` from Flutter | `app_bootstrap.dart:19-24` |
|
||||
| Bridge init | `rust.initStorage(dir: dir)` | `app_bootstrap.dart:23-25,108-133` |
|
||||
|
||||
### 6.2 Existing Avatar Handling
|
||||
|
||||
| Component | What | Location |
|
||||
|---|---|---|
|
||||
| UID to avatar path | `uid_to_avatar_path()` — base64 decode UID, encode each byte as 2 chars (a-p) | `adapter.rs:1561-1568` |
|
||||
| Client profile DTO | `avatar_path` field — set when `client.avatar_hash` and `unique_id` non-empty | `dto.rs:135-136`, `adapter.rs:1323-1332` |
|
||||
| No download | Currently no file download implementation exists | — |
|
||||
| No icon handling | No icon field/path in protocol DTO or adapter | — |
|
||||
|
||||
### 6.3 Server Identity in Chanora
|
||||
|
||||
Chanora currently tracks servers by **connection address** (`host:port`), not by server UID:
|
||||
|
||||
- Bookmarks: `WHERE host = ?1`
|
||||
- Recent servers: auto-saved by `cfg.address`
|
||||
- Prefetch cache: keyed by normalized host
|
||||
- ServerSnapshot: has `server_name` but no `server_uid`
|
||||
|
||||
The `virtualserver_unique_identifier` field is available from tsclientlib's state but is **not extracted** by the adapter.
|
||||
|
||||
### 6.4 tsclientlib File Transfer API
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Download method | `Connection::download_file()` |
|
||||
| Stream items | `StreamItem::FileDownload(FileDownloadResult { size, stream })` |
|
||||
| Failure | `StreamItem::FiletransferFailed(handle, error)` |
|
||||
| TCP handling | tsclientlib handles TCP connection + ftkey writing automatically |
|
||||
| Chanora's job | Read `size` bytes from the returned `TcpStream` |
|
||||
| Async behavior | `StreamItem::FileDownload` fires asynchronously, not inline with the request |
|
||||
|
||||
### 6.5 ts-bookkeeping Generated Fields
|
||||
|
||||
From the generated parser in `target/debug/build/ts-bookkeeping-*/out/`:
|
||||
|
||||
- `virtual_server_id: u64` — numeric, per-virtual-server, may change across restarts
|
||||
- `virtual_server_uid` — string, the `virtualserver_unique_identifier`
|
||||
|
||||
Both are available in the `InInitServer` struct from the init handshake but are not currently plumbed through.
|
||||
|
||||
---
|
||||
|
||||
## 7. TeaSpeak Server Internals (Authoritative)
|
||||
|
||||
Source: TeaSpeak Server at `https://git.did.science/TeaSpeak/Server/Server` (branch `new-groups`, commit `b54c6d4e`).
|
||||
|
||||
### 7.1 Avatar ID Derivation — Server Side
|
||||
|
||||
The server derives the avatar filename from the **client UID**, not from the avatar content:
|
||||
|
||||
```cpp
|
||||
// DataClient.cpp:242-244
|
||||
std::string DataClient::getAvatarId() {
|
||||
return hex::hex(base64::validate(this->getUid()) ? base64::decode(this->getUid()) : this->getUid(), 'a', 'q');
|
||||
}
|
||||
```
|
||||
|
||||
The same transform produces `client_base64HashClientUID` (shown to other clients):
|
||||
|
||||
```cpp
|
||||
// client.cpp:1113-1114
|
||||
bulk.put_unchecked("client_base64HashClientUID",
|
||||
hex::hex(base64::validate(info->client_unique_id) ? base64::decode(info->client_unique_id) : info->client_unique_id, 'a', 'q'));
|
||||
```
|
||||
|
||||
**This matches Chanora's existing `uid_to_avatar_path()` in `adapter.rs:1561-1568`.**
|
||||
|
||||
### 7.2 Avatar Upload Path
|
||||
|
||||
When a client uploads an avatar, the server stores it as `/avatar_<avatarId>`:
|
||||
|
||||
```cpp
|
||||
// file.cpp:696-702
|
||||
} else if (cmd["path"].as<std::string>().empty() && cmd["name"].string() == "/avatar") {
|
||||
...
|
||||
info.file_path = "/avatar_" + this->getAvatarId();
|
||||
transfer_response = file::server()->file_transfer().initialize_avatar_transfer(...);
|
||||
}
|
||||
```
|
||||
|
||||
The avatar file path is identity-based (from UID), not content-based.
|
||||
|
||||
### 7.3 `client_flag_avatar` — Who Computes the Hash?
|
||||
|
||||
**The CLIENT computes the MD5 and sends it to the server during upload.** The server stores it as a string property (`FLAG_USER_EDITABLE`). The server does NOT compute or verify the hash.
|
||||
|
||||
This means `client_flag_avatar` is:
|
||||
- Set by the uploading client
|
||||
- Stored verbatim by the server
|
||||
- Broadcast to other clients as part of the client properties
|
||||
- A reliable content fingerprint: same avatar bytes → same MD5 → same `client_flag_avatar` on any server
|
||||
|
||||
### 7.4 Icon IDs — Server Does NOT Compute CRC32
|
||||
|
||||
Icon IDs are **permission values**, not content hashes computed by the server:
|
||||
|
||||
```cpp
|
||||
// ConnectedClient.cpp:186-210 — client icon ID from permissions
|
||||
auto permission_flags = local_permissions->permission_flags(permission::i_icon_id);
|
||||
new_icon_id = value.value;
|
||||
updated_client_properties.emplace_back(property::CLIENT_ICON_ID);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// channel.cpp:1495-1504 — channel icon ID
|
||||
if(key == property::CHANNEL_ICON_ID) {
|
||||
auto icon_id = converter<uint32_t>::from_string_view(value);
|
||||
channel->permissions()->set_permission(permission::i_icon_id, { ... icon_id ... });
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
// server.cpp:76-89 — server icon ID
|
||||
SERVEREDIT_CHK_PROP_CACHED("virtualserver_icon_id", permission::b_virtualserver_modify_icon_id, int64_t)
|
||||
```
|
||||
|
||||
**The CLIENT computes the CRC32 during upload and uses it as the filename `/icon_<crc32>`.** The server stores the file and records the ID as a permission value. No server-side CRC32 or MD5 computation exists.
|
||||
|
||||
### 7.5 Per-Server Storage Layout
|
||||
|
||||
Avatars and icons are stored **per virtual server** on the server's filesystem:
|
||||
|
||||
```cpp
|
||||
// LocalFileSystem.cpp:39-45
|
||||
fs::path LocalFileSystem::server_path(const std::shared_ptr<VirtualFileServer> &server) {
|
||||
return fs::u8path(this->root_path_) / fs::u8path("server_" + std::to_string(server->server_id()));
|
||||
}
|
||||
// target_path = this->server_path(server) / "icons" / path;
|
||||
// target_path = this->server_path(server) / "avatars" / path;
|
||||
```
|
||||
|
||||
```
|
||||
<server_root>/
|
||||
server_<sid>/
|
||||
avatars/
|
||||
/avatar_<avatarId> ← one per client who uploaded
|
||||
icons/
|
||||
/icon_<id> ← one per unique icon
|
||||
```
|
||||
|
||||
### 7.6 File Transfer Protocol (Server Side)
|
||||
|
||||
Upload/delete/query routing:
|
||||
|
||||
```cpp
|
||||
// file.cpp:273-341 — delete routing
|
||||
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... delete_icons(...); }
|
||||
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... delete_avatars(...); }
|
||||
```
|
||||
|
||||
```cpp
|
||||
// file.cpp:483-523 — query routing
|
||||
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... query_icon_info(...); }
|
||||
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... query_avatar_info(...); }
|
||||
```
|
||||
|
||||
Transfer initialization returns ftkey and metadata:
|
||||
|
||||
```cpp
|
||||
// file.cpp:759-761
|
||||
result.put_unchecked(0, "ftkey", transfer->transfer_key);
|
||||
result.put_unchecked(0, "seekpos", transfer->file_offset);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// file.cpp:887-899
|
||||
result.put_unchecked(0, "ftkey", transfer->transfer_key);
|
||||
result.put_unchecked(0, "proto", "1");
|
||||
result.put_unchecked(0, "size", transfer->expected_file_size);
|
||||
```
|
||||
|
||||
### 7.7 Key Takeaway for Chanora
|
||||
|
||||
| Who does what | Avatar | Icon |
|
||||
|---|---|---|
|
||||
| **Uploader (client)** computes | MD5 of avatar bytes → sets `client_flag_avatar` | CRC32 of icon bytes → filename `/icon_<crc32>` |
|
||||
| **Server** does | Stores file as `/avatar_<uid>`, saves property | Stores file as `/icon_<id>`, saves permission |
|
||||
| **Other clients** receive | `client_flag_avatar` (MD5) as a property update | `icon_id` (CRC32) as a property update |
|
||||
| **Chanora cache key** | `av_<md5>.dat` — content fingerprint | `ic_<crc32>.dat` — content fingerprint |
|
||||
|
||||
The content hash is computed once (by the uploader) and then broadcast as a property. Chanora never needs to hash anything — it just uses the protocol-provided values as cache keys.
|
||||
|
||||
---
|
||||
|
||||
## 8. Design Implications
|
||||
|
||||
### 8.1 The Core Insight
|
||||
|
||||
The TS3 protocol provides content hashes as part of normal server-to-client updates:
|
||||
|
||||
| Event | Data provided by server | What Chanora gets for free |
|
||||
|---|---|---|
|
||||
| Client enters view | `client_flag_avatar` = MD5 of avatar bytes | Content key for blob store |
|
||||
| Channel update | `channel_icon_id` = CRC32 of icon bytes | Content key for blob store |
|
||||
| Server update | `virtualserver_icon_id` = CRC32 of icon bytes | Content key for blob store |
|
||||
|
||||
No hashing needed on the client side. The protocol is **already content-addressed**.
|
||||
|
||||
### 8.2 Recommended Cache Architecture
|
||||
|
||||
```
|
||||
<app_cache_dir>/chanora/
|
||||
blobs/ ← cacache content store root
|
||||
content-v2/ ← content-addressed by SHA-512
|
||||
<sha512-hex>/data ← raw blob bytes
|
||||
index-v2/ ← key → content mapping
|
||||
```
|
||||
|
||||
Where `<app_cache_dir>` is the platform cache directory (not the support directory used by `chanora_storage`). Chanora's `BlobCache` maps protocol keys (`av_<md5>`, `ic_<crc32>`) to `cacache` string keys. Physical layout is managed by `cacache`.
|
||||
|
||||
**Lookup flow:**
|
||||
1. Server sends `client_flag_avatar = "a1b2c3d4..."` for user X
|
||||
2. Check: does `blobs/av_a1b2c3d4....dat` exist?
|
||||
3. Yes → use it, zero downloads (works for ANY server)
|
||||
4. No → download from `/avatar_<uid_base64>` → save as `blobs/av_a1b2c3d4....dat`
|
||||
|
||||
**Same for icons with `ic_<crc32>.dat`.**
|
||||
|
||||
### 8.3 Why This Beats Alternatives
|
||||
|
||||
| Approach | Dedup | Globally unique key | Needs server UID plumbing | Needs hardlinks | Complexity |
|
||||
|---|---|---|---|---|---|
|
||||
| `<server_uid>/<hash>.dat` | No | No (UID not guaranteed unique) | Yes | Optional | Medium |
|
||||
| `<host>_<port>/<hash>.dat` | No | Yes | No | Optional | Medium |
|
||||
| `<host>_<port>/<hash>.dat` + hardlinks | Yes | Yes | No | Yes | Medium-High |
|
||||
| **`blobs/av_<hash>.dat` (flat)** | **Yes** | **Yes (content hash)** | **No** | **No** | **Low** |
|
||||
|
||||
### 8.4 Trade-offs
|
||||
|
||||
| Pro | Con |
|
||||
|---|---|
|
||||
| Zero duplication across all servers | "Clear cache for server X only" requires metadata layer (Phase 3+) |
|
||||
| No hardlinks needed | Orphan cleanup requires scanning for unreferenced blobs |
|
||||
| No server UID plumbing needed | Cannot distinguish same-hash-different-content for icons (CRC32 collision) |
|
||||
| Simplest possible implementation | — |
|
||||
| Freshness = hash change = different filename (automatic) | — |
|
||||
| Cross-platform (just file I/O) | — |
|
||||
|
||||
### 8.5 Phased Implementation
|
||||
|
||||
| Phase | What | Delivers |
|
||||
|---|---|---|
|
||||
| 1 | Protocol download (raw bytes via adapter, no cache) | Working download pipeline |
|
||||
| 2 | Bridge + Flutter display (`Image.memory()`) | Visible avatars in UI |
|
||||
| 3 | `chanora_cache` crate: cacache-backed blob cache, separate crate, cache dir, mtime eviction | Zero re-downloads, zero duplication |
|
||||
| 4 | Session orchestration (coalescing, rate limiting, negative cache) | Anti-flood, robustness |
|
||||
| 5 | Cache management (clear all, orphan cleanup, optional per-server metadata) | User control |
|
||||
|
||||
---
|
||||
|
||||
## 9. Resolved Questions
|
||||
|
||||
### Q1: Icon CRC32 Collisions — Accept with Size Guard
|
||||
|
||||
**Risk assessment:** CRC32 produces a 32-bit hash. For N unique icons, the Birthday paradox gives collision probability ≈ N² / (2 × 2³²).
|
||||
|
||||
| Icons (N) | Collision probability |
|
||||
|---|---|
|
||||
| 100 | ~0.0001% (negligible) |
|
||||
| 1,000 | ~0.01% (negligible) |
|
||||
| 10,000 | ~1.2% (marginal) |
|
||||
| 65,536 | ~50% (likely) |
|
||||
|
||||
A single user typically encounters fewer than 1,000 unique icons across all servers. The practical collision risk is negligible.
|
||||
|
||||
**What happens on collision:** Wrong icon displayed for a channel/client/server. This is a visual glitch, not a security issue. The icon will appear incorrect until the cache is cleared.
|
||||
|
||||
**Existing practice:** Qint explicitly notes CRC32 collisions (`filecache.rs:4`) but does NOT guard against them — they only refresh icons by mtime. No other TS3 client guards against CRC32 collisions.
|
||||
|
||||
**Recommendation:** Accept CRC32 as the cache key. Add a lightweight **file size guard**: when downloading an icon, if `ic_<crc32>.dat` already exists but has a different size than the `ftinitdownload` response reported, re-download. File size is available from the protocol (`msg.size` in `InFileDownloadPart`). This catches most collisions (different content = different size with high probability) without computing a secondary hash.
|
||||
|
||||
**Decision:** CRC32 + file size guard. No SHA256 overhead needed.
|
||||
|
||||
---
|
||||
|
||||
### Q2: `client_myteamspeak_avatar` — Defer Indefinitely
|
||||
|
||||
**What it is:** A string property (`Option<String>` in ts-bookkeeping) broadcast alongside `client_flag_avatar`. It represents a myTeamSpeak cross-server avatar — a user linked to a myTeamSpeak account can set a global avatar that follows them across all servers.
|
||||
|
||||
**Current state in Chanora's dependency chain:**
|
||||
- ts-bookkeeping exposes it: `InInitServer` has `my_team_speak_avatar: Option<String>`
|
||||
- TeaSpeakLibrary tracks `client_myteamspeak_id` but not the avatar
|
||||
- tsclientlib exposes it as a property on client state
|
||||
|
||||
**Value for Chanora:**
|
||||
- myTeamSpeak is a TeamSpeak-specific cloud service (account sync, cross-server features)
|
||||
- Chanora is an independent client — no myTeamSpeak account integration is planned
|
||||
- The property may contain a URL or identifier that requires myTeamSpeak API access to resolve
|
||||
- Without myTeamSpeak integration, the avatar cannot be fetched
|
||||
|
||||
**Recommendation:** Defer indefinitely. If Chanora ever integrates myTeamSpeak accounts, this can be handled as a separate avatar source (URL-based HTTP download) alongside the existing protocol-based avatar download. The cache architecture supports this — just add a different blob prefix (e.g., `mt_<hash>.dat`).
|
||||
|
||||
**Decision:** Out of scope for MVP and foreseeable roadmap.
|
||||
|
||||
---
|
||||
|
||||
### Q3: Cache Backing Store — `cacache` Wrapper in Separate `chanora_cache` Crate
|
||||
|
||||
**Decision:** Use `cacache` as the backing store inside `chanora_cache`. Not a custom flat-file implementation.
|
||||
|
||||
**Why cacache won over custom:**
|
||||
|
||||
1. **Crash safety is production-tested.** `cacache` handles partial writes, power loss, crash mid-write. A custom implementation would need to get `sync_all` + atomic rename right — one bug = corrupted cache. Even though cache data is disposable (reconstructible from server), `cacache` eliminates this entire class of bugs.
|
||||
|
||||
2. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. The hard parts (atomic writes, integrity, content dedup) are owned by `cacache`, tested by the npm ecosystem.
|
||||
|
||||
3. **Integrity verification on every read.** SSRI verification detects corruption, bit rot, partial writes automatically. A custom impl would need to add this separately or accept silent corruption.
|
||||
|
||||
4. **Content dedup by SHA-512.** Same avatar on two servers = stored once automatically. The protocol's MD5/CRC32 keys map to `cacache` string keys; content dedup happens at the SHA-512 layer underneath.
|
||||
|
||||
**What about the downsides:**
|
||||
|
||||
| Concern | Assessment |
|
||||
|---|---|
|
||||
| ~6 transitive deps | `sha2` already in tree via `chacha20poly1305`. `serde_json`, `tempfile`, `digest` are lightweight. Acceptable for the safety benefit. |
|
||||
| SHA-512 overhead on every write/read | For <100KB avatars, SHA-512 takes ~0.1ms. Negligible. |
|
||||
| Opaque on-disk format | `cacache` provides `ls()` API for enumeration and inspection. Not as simple as `ls blobs/` but adequate. |
|
||||
| `cacache` has no built-in LRU eviction | We write a custom eviction pass using `cacache::ls()` + timestamp sort. ~20 lines. Same complexity as custom impl's eviction. |
|
||||
|
||||
**Separate crate rationale:**
|
||||
|
||||
- `chanora_cache` is separate from `chanora_storage` because cache data has different durability semantics (disposable vs persistent), different backup semantics (excluded vs included), and different directory placement (cache dir vs support dir).
|
||||
- `chanora_cache` lives in the platform's cache directory (`getApplicationCacheDirectory()`). `chanora_storage` lives in the support directory (`getApplicationSupportDirectory()`).
|
||||
- Bridge init is separate: `init_cache(cache_dir)` vs `init_storage(support_dir)`.
|
||||
|
||||
**API design:**
|
||||
|
||||
```rust
|
||||
pub struct BlobCache { cache_dir: PathBuf, max_bytes: u64 }
|
||||
impl BlobCache {
|
||||
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError>;
|
||||
pub async fn put(&self, prefix: &str, key: &str, data: &[u8]) -> Result<(), BlobCacheError>;
|
||||
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError>;
|
||||
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError>;
|
||||
pub async fn clear(&self) -> Result<(), BlobCacheError>;
|
||||
pub async fn total_size(&self) -> Result<u64, BlobCacheError>;
|
||||
pub async fn evict(&self) -> Result<(), BlobCacheError>;
|
||||
}
|
||||
```
|
||||
|
||||
All methods are async (cacache is async-native). Key validation at API boundary (`av_` = 32 hex chars, `ic_` = decimal digits).
|
||||
|
||||
---
|
||||
|
||||
### Q4: Per-Blob Metadata — No Metadata Sidecars (Resolved)
|
||||
|
||||
**Original options:**
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|---|---|---|
|
||||
| SQLite (chanora.db) | ACID, queryable, already in use | Schema migration, couples cache to bookmark DB |
|
||||
| JSON sidecar files | Simple, self-contained, easy to debug | Write amplification (2 files per blob), concurrent write risk |
|
||||
| In-memory only | Simplest | Lost on restart, can't do orphan cleanup offline |
|
||||
| **No metadata (mtime-based)** | **Simplest, zero write amplification, 1 file per blob** | **No per-blob metadata beyond mtime** |
|
||||
|
||||
**Why no metadata is sufficient:**
|
||||
|
||||
1. **Content is immutable.** A given hash (MD5 or CRC32) always maps to the same bytes. There is no "stale content" problem — if the hash changes, it's a new file with a new name. No invalidation needed.
|
||||
|
||||
2. **mtime = insertion time.** Since content is never modified after write, the filesystem mtime equals the time the blob was cached. This is sufficient for "delete oldest files first" eviction.
|
||||
|
||||
3. **Write amplification avoided.** One file per blob (just the data) instead of two (data + JSON sidecar). For a cache that may hold thousands of small files, this matters.
|
||||
|
||||
4. **Eviction is simple.** `walk dir → stat → sort by mtime → delete oldest`. No JSON parsing, no schema, no migration.
|
||||
|
||||
5. **Per-server metadata deferred.** "Clear cache for server X only" and orphan cleanup are post-MVP features. If needed, a refs-layer can be added later without changing the blob layout.
|
||||
|
||||
**Oracle consultation:** Oracle recommended this approach explicitly — no metadata files, mtime-based eviction, separate crate. The immutability guarantee makes metadata redundant.
|
||||
|
||||
**Decision:** No metadata sidecars. One file per blob. Mtime-based eviction. Per-server metadata deferred to post-MVP.
|
||||
|
||||
---
|
||||
|
||||
### Q5: File Transfer Address Failover — Not Needed
|
||||
|
||||
**What the protocol provides:**
|
||||
|
||||
The TeaSpeak client's `InitializedTransferProperties` returns `addresses[]` — an array of `{serverAddress, serverPort}`. The official TS3 client can try multiple addresses for failover.
|
||||
|
||||
**What tsclientlib provides:**
|
||||
|
||||
```rust
|
||||
// tsclientlib/src/lib.rs:1373-1375
|
||||
let ip = msg.ip.unwrap_or_else(|| self.client.address.ip());
|
||||
let addr = SocketAddr::new(ip, msg.port);
|
||||
TcpStream::connect(&addr).await
|
||||
```
|
||||
|
||||
tsclientlib's `InFileDownloadPart` has `ip: Option<IpAddr>` — **single IP only**, not an array. If the server provides an IP, it uses that. Otherwise, it falls back to the connection address. **No multi-address failover.**
|
||||
|
||||
**What ts-bookkeeping parses:**
|
||||
|
||||
```rust
|
||||
pub struct InFileDownloadPart {
|
||||
pub client_filetransfer_id: u16,
|
||||
pub server_filetransfer_id: u16,
|
||||
pub filetransfer_key: String,
|
||||
pub port: u16,
|
||||
pub size: u64,
|
||||
pub protocol: u8,
|
||||
pub ip: Option<IpAddr>, // ← single optional IP
|
||||
}
|
||||
```
|
||||
|
||||
**The server's `notifystartdownload` response** sends `ip` as an optional single value, not an array. The TeaSpeak client's `addresses[]` is a higher-level abstraction (likely the client's own fallback logic), not a protocol feature.
|
||||
|
||||
**Recommendation:** Chanora follows tsclientlib's existing behavior — use `msg.ip` or fallback to connection address. No custom failover logic needed. If the TCP connection fails, the download fails and retries follow the exponential backoff strategy from the design doc.
|
||||
|
||||
**Decision:** Single address (from tsclientlib). No failover needed.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Chanora Software Architecture Description
|
||||
|
||||
**Lifecycle:** SWE.2 Software Architectural Design
|
||||
**Document status:** DV meeting baseline candidate
|
||||
**Date:** 2026-05-29
|
||||
**Direct upstream source:** `docs/srs.md`
|
||||
**Related system allocation:** `docs/sysdes.md`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This Software Architecture Description defines Chanora's software architecture for DV review. It bridges SRS software requirements to SWE.3 detailed design and to SWE.5/SWE.6 verification planning.
|
||||
|
||||
This baseline captures the architecture visible in the current repository. It is sufficient for DV traceability review, while deeper per-module algorithms remain in `docs/architecture/sdd.md` and source-level design.
|
||||
|
||||
## 2. Architectural Scope
|
||||
|
||||
Chanora is a Flutter application with a Rust core. Flutter owns the user-facing shell, Material 3 widgets, localization, permission UX, and platform service presentation. Rust owns connection orchestration, protocol isolation, audio processing, storage coordination, diagnostics, server resolution, prefetch policy, and bridge DTOs.
|
||||
|
||||
## 3. Upstream SRS Allocation
|
||||
|
||||
| SRS group | Architectural allocation |
|
||||
|---|---|
|
||||
| SRS-003, SRS-008 through SRS-016 | Cross-platform app shell, Flutter UI, Rust core, platform adapters |
|
||||
| SRS-017 through SRS-030 | Flutter UI, state presentation, connection and voice controls |
|
||||
| SRS-031 through SRS-035 | Bridge layer and typed DTO boundary |
|
||||
| SRS-036 through SRS-043 | Rust core connection lifecycle and state behavior |
|
||||
| SRS-044 through SRS-053 | Protocol adapter and TeamSpeak-compatible server boundary |
|
||||
| SRS-054 through SRS-061 | State synchronization and replay/reducer verification hooks |
|
||||
| SRS-062 through SRS-083 | Audio subsystem, DSP, codec, PTT, mute/deaf, metering |
|
||||
| SRS-084 through SRS-095 | Storage, secure storage, identity, diagnostics-sensitive data |
|
||||
| SRS-096 through SRS-102 | Diagnostics, export, redaction, troubleshooting hooks |
|
||||
| SRS-103 through SRS-123 | Platform adapters, packaging, release behavior |
|
||||
| SRS-124 through SRS-143 | Verification support, analysis requirements, traceability rules |
|
||||
| SRS-144 through SRS-184 | Material 3, adaptive UI, accessibility, localization, Unicode, app initialization |
|
||||
| SRS-185 through SRS-218 | Platform baselines, PTT capability, transmit mode, no automatic telemetry, benchmark advisory |
|
||||
|
||||
## 4. Component Architecture
|
||||
|
||||
| Component | Repository location | Responsibility | Direct architectural dependencies |
|
||||
|---|---|---|---|
|
||||
| Flutter app shell | `apps/chanora_flutter/lib/main.dart`, services, widgets | App startup, screen composition, user actions, localization, Material 3 UI | Generated Rust bridge, platform plugins, Flutter services |
|
||||
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
|
||||
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
|
||||
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
|
||||
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state, bridge-facing event DTOs | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
||||
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
|
||||
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
|
||||
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
|
||||
| Storage | `crates/chanora_storage` | Bookmarks, identities, encrypted local data, platform keyring integration | Rust core, platform secure storage |
|
||||
| Diagnostics | `crates/chanora_diagnostics` | Redaction, log sink, export bundle, known-secret registry | Rust core, Flutter diagnostics UI |
|
||||
| Server resolver | `crates/chanora_resolver` | SRV/TSDNS/DNS fallback resolution | Rust core, prefetch crate |
|
||||
| Server prefetch | `crates/chanora_prefetch`, Flutter `prefetch_debouncer.dart` | Invisible host-field resolution warming, TTL cache, generation safety | Resolver, Flutter connect UI, Rust core |
|
||||
| Cache | `crates/chanora_cache` | Typed caching layer for server resolution results and other transient data | Rust core, resolver, prefetch |
|
||||
|
||||
## 5. Static Architecture View
|
||||
|
||||
```text
|
||||
Flutter UI/widgets/services
|
||||
-> generated Dart bridge API
|
||||
-> chanora_bridge
|
||||
-> chanora_core
|
||||
-> chanora_protocol -> tsclientlib -> external compatible server
|
||||
-> chanora_state
|
||||
-> chanora_audio -> platform audio APIs / Opus / DSP
|
||||
-> chanora_storage -> platform secure storage / SQLite
|
||||
-> chanora_diagnostics
|
||||
-> chanora_cache -> chanora_prefetch -> chanora_resolver -> network DNS/TSDNS
|
||||
```
|
||||
|
||||
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
||||
|
||||
Current Core locality note: the public Core Interface remains available through `chanora_core::*` re-exports, while branch `simplify-project-review` has started moving internal Core responsibilities into focused Modules (`events.rs`, `network_diagnostics.rs`). This is an internal maintainability split, not a public Interface change.
|
||||
|
||||
## 6. Runtime Flow Architecture
|
||||
|
||||
### 6.1 Connect Flow
|
||||
|
||||
```text
|
||||
User enters host/bookmark
|
||||
-> Flutter connect widgets
|
||||
-> optional prefetch debounce
|
||||
-> bridge connect command
|
||||
-> Rust core supervisor
|
||||
-> resolver / prefetch cache
|
||||
-> protocol adapter
|
||||
-> external compatible server
|
||||
-> state snapshot/events
|
||||
-> bridge event stream
|
||||
-> Flutter state mapper and widgets
|
||||
```
|
||||
|
||||
### 6.2 Voice Flow
|
||||
|
||||
```text
|
||||
Microphone / platform input
|
||||
-> audio capture unit
|
||||
-> DSP chain: HPF, NS, AEC, AGC where active
|
||||
-> PTT/mute/transmit gate
|
||||
-> Opus encode
|
||||
-> protocol adapter
|
||||
-> external compatible server
|
||||
|
||||
External server voice
|
||||
-> protocol adapter
|
||||
-> jitter/decode path
|
||||
-> mixer / per-user controls
|
||||
-> platform output
|
||||
```
|
||||
|
||||
### 6.3 Diagnostics Flow
|
||||
|
||||
```text
|
||||
Runtime event or error
|
||||
-> diagnostic log sink / known-secret registry
|
||||
-> redactor
|
||||
-> user-initiated export bundle
|
||||
-> Flutter share/export surface
|
||||
```
|
||||
|
||||
## 7. Interface Catalogue
|
||||
|
||||
| Interface | Producer | Consumer | Architectural rule |
|
||||
|---|---|---|---|
|
||||
| Bridge command DTOs | Flutter generated API | `chanora_bridge`, Rust core | Stable typed DTOs; no raw protocol-library types cross to Flutter |
|
||||
| Bridge event DTOs | Rust core / bridge | Flutter services/widgets | User-safe errors and capability fields are explicit |
|
||||
| Protocol DTOs | `chanora_protocol` | Rust core, state sync | Protocol adapter isolates `tsclientlib` |
|
||||
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; Windows/Linux desktop VAD-backed `VoiceActivity` is enabled only where runtime evidence exists, with unsupported platforms disabled/deferred |
|
||||
| Storage records | Storage crate | Rust core / Flutter UI via bridge | Secrets stay behind secure-storage abstraction |
|
||||
| Diagnostic bundles | Diagnostics crate | Flutter diagnostics UI | Redaction runs before export or display |
|
||||
| Platform capability records | Platform adapters/audio/PTT backends | UI and release record | UI/release wording must not over-claim capability |
|
||||
|
||||
## 8. Dependency Rules
|
||||
|
||||
| Rule | Rationale |
|
||||
|---|---|
|
||||
| Flutter UI depends on generated bridge APIs, not Rust internals | Keeps UI stable across Rust implementation changes |
|
||||
| Rust core orchestrates crates but protocol/audio/storage crates remain separately testable | Supports SWE.4 unit verification and bounded responsibilities |
|
||||
| Protocol adapter is the only component that owns `tsclientlib` coupling | Protects the app from protocol-library leakage |
|
||||
| Diagnostics redaction must be reusable by runtime logging and export | Prevents split redaction behavior |
|
||||
| Platform-specific behavior stays in platform adapters or audio platform units | Keeps cross-platform logic testable and reduces conditional sprawl |
|
||||
| Release claims consume capability records and release evidence | Prevents over-claiming PTT, signing, packaging, or secure-storage behavior |
|
||||
|
||||
## 9. Non-Functional Allocation
|
||||
|
||||
| Concern | Architectural mechanism | Verification owner |
|
||||
|---|---|---|
|
||||
| Real-time audio responsiveness | Rust audio subsystem, benchmark advisory, bounded callback behavior | Audio / Platform QA |
|
||||
| Privacy and no automatic telemetry | User-initiated diagnostics, no automatic upload policy | Security / Privacy QA |
|
||||
| Secure secret handling | Platform secure-storage abstraction and encrypted local storage | Security / QA |
|
||||
| Cross-platform UI | Flutter Material 3, design tokens, responsive widgets | Software QA / UX |
|
||||
| Protocol compatibility | `tsclientlib` adapter isolation and compatible-server matrix | Protocol / Integration QA |
|
||||
| Release reproducibility | CI, build scripts, artifact hashes, release-readiness record | Release / Operations QA |
|
||||
|
||||
## 10. Architectural Decisions Captured by This Baseline
|
||||
|
||||
| Decision | Architectural outcome |
|
||||
|---|---|
|
||||
| Flutter + Rust split | Flutter owns presentation; Rust owns protocol/audio/storage/diagnostics core behavior |
|
||||
| `tsclientlib` isolation | Protocol compatibility is behind `chanora_protocol` |
|
||||
| Secure storage abstraction | Platform storage details do not leak into UI or unrelated crates |
|
||||
| Advisory audio benchmarks | Performance regressions are surfaced without making CI a hard release gate at this stage |
|
||||
| PTT capability levels | Platform PTT support is represented as capability data and must match release wording |
|
||||
| VoiceActivity platform scope | Windows/Linux desktop `VoiceActivity` is implemented through the capture VAD path; unsupported platforms remain disabled/deferred until backend allocation and runtime verification exist |
|
||||
| No automatic diagnostic upload in MVP | Diagnostics are local and user-initiated unless future approved requirements change policy |
|
||||
|
||||
## 11. Verification Handoff
|
||||
|
||||
| Verification plan | SAD handoff |
|
||||
|---|---|
|
||||
| SWE.4 | Component boundaries define unit-test ownership for Flutter services/widgets and Rust crates |
|
||||
| SWE.5 | Interface catalogue and runtime flows define integration paths |
|
||||
| SWE.6 | SRS allocation and acceptance flows define software acceptance evidence |
|
||||
| SYS.4 | Platform capability and external-server boundaries define system integration evidence |
|
||||
|
||||
## 12. Traceability to SRS
|
||||
|
||||
This SAD derives only from `docs/srs.md`. The broad SRS group-to-component allocation in section 3 is the controlling SWE.2 trace for DV. Detailed item-level trace is represented by the SRS coverage matrix and `docs/governance/traceability-matrix.md`.
|
||||
|
||||
## 13. Open Architecture Risks
|
||||
|
||||
| Risk | Impact | Control |
|
||||
|---|---|---|
|
||||
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
|
||||
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
|
||||
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
|
||||
| Android runtime verification is not automatic in local reviews | Android permission/audio/lifecycle regressions can pass Rust-only tests | Require `adb devices -l` with a connected device/emulator and Android smoke evidence before claiming Android runtime success |
|
||||
| Protocol voice packet re-export is an intentional exception to full protocol isolation | Future changes may accidentally widen the protocol/audio Seam | Document and keep the voice wire exception narrow, or move packet construction fully into `chanora_protocol` |
|
||||
|
||||
## 14. DV Conclusion
|
||||
|
||||
This SWE.2 baseline is sufficient to remove the missing-SAD traceability gap for DV review. It does not replace candidate test evidence or final release approval.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Chanora Software Detailed Design
|
||||
|
||||
**Lifecycle:** SWE.3 Software Detailed Design and Unit Construction Handoff
|
||||
**Document status:** DV meeting baseline candidate
|
||||
**Date:** 2026-05-29
|
||||
**Direct upstream source:** `docs/architecture/sad.md`
|
||||
**Related software requirements:** `docs/srs.md`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This Software Detailed Design defines the module-level design details needed for SWE.4 unit verification and SWE.5 integration verification. It is based on the current repository layout and the SWE.2 architecture baseline.
|
||||
|
||||
## 2. Module Catalogue
|
||||
|
||||
| SDD module | Source location | Primary responsibility | Upstream SAD component |
|
||||
|---|---|---|---|
|
||||
| SDD-MOD-001 Flutter app bootstrap | `apps/chanora_flutter/lib/services/app_bootstrap.dart`, `main.dart` | Initialize Rust bridge, localization, app services, theme/design baseline | Flutter app shell |
|
||||
| SDD-MOD-002 Connect UI | `apps/chanora_flutter/lib/widgets/connect_widgets.dart` | Host/bookmark inputs, connect actions, pre-request UX | Flutter widget layer |
|
||||
| SDD-MOD-003 Snapshot and channel UI | `snapshot_view.dart`, `snapshot_state_mapper.dart`, `channel_spacer.dart` | Present channel tree, clients, and mapped state | Flutter widget/service layer |
|
||||
| SDD-MOD-004 Chat UI | `chat_views.dart`, `bbcode_text.dart` | Channel text rendering and BBCode-safe display | Flutter widget layer |
|
||||
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
||||
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
||||
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs` | Connection orchestration entry point, re-exports from submodules | Rust core |
|
||||
| SDD-MOD-008a Core events module | `core/chanora_core/src/events.rs` | Public event/bridge-facing DTOs, connection state DTOs | Rust core |
|
||||
| SDD-MOD-008b Core network diagnostics | `core/chanora_core/src/network_diagnostics.rs` | Private connect/loss counters, last-loss ring buffer for diagnostics | Rust core |
|
||||
| SDD-MOD-008c Core PTT state | `core/chanora_core/src/ptt.rs` | Push-to-talk state machine, capability level, key bindings | Rust core |
|
||||
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
||||
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
||||
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
||||
| SDD-MOD-012 Storage | `crates/chanora_storage/src/lib.rs` | Bookmarks, identity storage, encrypted local records, keyring abstraction | Storage |
|
||||
| SDD-MOD-013 Diagnostics | `crates/chanora_diagnostics/src/lib.rs` | Redaction, log sink, known-secret registry, export bundle | Diagnostics |
|
||||
| SDD-MOD-014 Resolution and prefetch | `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` | SRV/TSDNS/DNS fallback and generation-safe resolution warming | Server resolver / prefetch |
|
||||
| SDD-MOD-015 Server resolver | `crates/chanora_resolver/src/lib.rs` | SRV record, TSDNS, and DNS A/AAAA fallback resolution | Server resolver |
|
||||
| SDD-MOD-016 Server prefetch | `crates/chanora_prefetch/src/lib.rs` | TTL-based resolution cache, invisible host-field warming | Server prefetch |
|
||||
| SDD-MOD-017 Cache | `crates/chanora_cache/src/lib.rs` | Typed caching layer for server resolution results and other transient data | Cache |
|
||||
| SDD-MOD-018 Build and release hooks | `.github/workflows/`, `tools/`, platform project files | CI, unsigned iOS build, benchmark advisory, platform smoke procedures | Release / platform architecture |
|
||||
|
||||
## 3. Bridge Boundary Design
|
||||
|
||||
The bridge boundary is the only supported Flutter-to-Rust command path. Dart code uses generated APIs under `apps/chanora_flutter/lib/src/rust/`; Rust exposes bridge functions through `crates/chanora_bridge/src/api.rs`.
|
||||
|
||||
Design rules:
|
||||
|
||||
| Rule | Detail |
|
||||
|---|---|
|
||||
| DTO stability | DTO fields must be explicit and serializable through Flutter Rust Bridge generation |
|
||||
| Error safety | Rust errors exposed to Flutter must be user-safe or mapped before display |
|
||||
| Secret handling | Secrets may cross only as command inputs or protected DTO fields and must be registered for diagnostic redaction where relevant |
|
||||
| Capability reporting | Platform and PTT capability fields must reflect actual active backend state |
|
||||
| Regeneration control | Generated bridge files are implementation artifacts and must be regenerated when bridge API signatures change |
|
||||
|
||||
## 4. Connection and State Design
|
||||
|
||||
| Detail | Design |
|
||||
|---|---|
|
||||
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
|
||||
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
|
||||
| Core internal Modules | `lib.rs` remains the public Interface and orchestration entry point; `events.rs` owns public event/bridge-facing DTOs re-exported by `lib.rs`; `network_diagnostics.rs` owns private connect/loss counters and the last-loss ring buffer |
|
||||
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
|
||||
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
|
||||
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
|
||||
| Reducers | `chanora_state` owns snapshot/delta reducer design with unit coverage for snapshot, delta, reconnect, duplicate normalization, disconnected/lost suppression, unknown-client voice activity, deterministic ordering, and channel-delete/client cleanup. Current runtime UI refresh still flows through `chanora_core` snapshot/probe paths; full live-event folding through `chanora_state::reduce` is an integration follow-up. |
|
||||
|
||||
## 5. Audio Detailed Design
|
||||
|
||||
| Audio element | Design detail |
|
||||
|---|---|
|
||||
| Capture/playback | Platform-specific units handle Android, iOS, desktop/fallback paths behind Rust audio abstractions |
|
||||
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
|
||||
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
|
||||
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and `VoiceActivity`; `VoiceActivity` is active for Windows/Linux desktop capture when VAD is configured, while mobile, macOS, and unverified-platform enablement remain deferred |
|
||||
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
|
||||
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
|
||||
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
|
||||
| Render peak limiter | `voice_render::limit_peak_inplace` is a single-pass, allocation-free per-frame peak scaler applied in both the macOS and iOS render callbacks before the i16 downmix; default threshold 0.99 prevents hard clipping on multi-client mixes that sum past 0 dBFS while remaining transparent for normal voice levels (allocation-free, lock-free, safe on the realtime audio thread) |
|
||||
| macOS render cadence (producer + ring) | `ios_voice_unit.rs:851-961` runs a 20 ms tokio producer task that calls `AudioHandler::fill_buffer(1920)` and `force_push`es each sample into a `crossbeam ArrayQueue<f32>` (SPSC-effective, MPMC-but-wait-free-per-end); ring capacity 12000 samples ≈ 6.25× pull quantum; 100 ms prebuffer (`PREBUFFER_SAMPLES = 9600` stereo f32) before the VPIO render callback starts draining, matching Mumble's playout margin and WebRTC's kStartDelayMs order of magnitude |
|
||||
| iOS render cadence (direct-fill) | `ios_voice_unit.rs:968-1034` does `AudioHandler::fill_buffer` directly in the VPIO render callback (VPIO on iOS requests 480-frame ≈ 10 ms slices that align with tsclientlib's 20 ms Opus frame); scratch buffer preallocated to 4096×2 f32 at setup time so the realtime callback never `resize()`s; `try_lock` (not `lock`) on the AudioHandler mutex so contention never stalls the realtime IO thread; on `WouldBlock` the callback emits silence and increments `callback_xrun` |
|
||||
| VPIO ducking config (macOS 14+) | `ios_voice_unit.rs` writes an 8-byte `AuVoiceIoOtherAudioDuckingConfiguration` struct (`m_enable_advanced_ducking = 0` disables dynamic voice-activity-driven ducking; `m_ducking_level = kAUVoiceIOOtherAudioDuckingLevelMin = 10`) to selector `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` (= 2108) on the VoiceProcessingIO AudioUnit at startup, minimising the ducking of other apps' audio during a voice session; on macOS 13 the property is silently ignored (VPIO returns the default ducking behaviour) and the code logs a debug message and continues |
|
||||
| Benchmarks | Realtime capture, Opus, and resampler benchmarks provide advisory baseline evidence |
|
||||
|
||||
## 6. Storage and Secret Design
|
||||
|
||||
| Storage item | Design detail |
|
||||
|---|---|
|
||||
| Bookmarks | Stored locally through the storage crate and surfaced in Flutter connect UI |
|
||||
| Identity references | Stored through `IdentityFileStore` and platform secure storage where available |
|
||||
| Passwords/secrets | Encrypted at rest using the current storage design; Android Keystore-backed DEK is deferred and must be disclosed |
|
||||
| CI keyring behavior | CI disables real keyring access with `CHANORA_DISABLE_KEYRING=1` to avoid headless blocking |
|
||||
| Fallback behavior | Platform fallback modes must be represented as limitations in release/security evidence |
|
||||
|
||||
## 7. Diagnostics Detailed Design
|
||||
|
||||
| Diagnostic element | Design detail |
|
||||
|---|---|
|
||||
| Log sink | Runtime logs can be captured by diagnostic sinks for export |
|
||||
| Known-secret registry | Runtime secrets are registered for redaction where applicable |
|
||||
| Redactor | Redacts configured sensitive patterns before export |
|
||||
| Export bundle | Diagnostic export is JSON-based and user-initiated |
|
||||
| Upload policy | MVP has no automatic diagnostic, telemetry, or crash upload |
|
||||
|
||||
## 8. Flutter UI Detailed Design
|
||||
|
||||
| UI area | Design detail |
|
||||
|---|---|
|
||||
| Design tokens | `chanora_tokens.dart` centralizes product styling over Material 3 |
|
||||
| Platform capability display | `platform_capabilities.dart` and PTT capability widgets expose platform-specific support honestly |
|
||||
| Localization | Generated localization files provide English and Simplified Chinese resources |
|
||||
| Responsive behavior | Current widgets support compact/mobile-oriented layouts; expanded side-pane hardening remains P1/P2 as recorded |
|
||||
| Accessibility | Critical status should use text/icons/semantics and not color alone; verification remains through UI tests/audit |
|
||||
| UI settings persistence | `UiPreferencesService` persists host, nickname, permission explanation state, and theme mode through `shared_preferences`; invalid stored theme values fall back to system theme |
|
||||
|
||||
## 9. Build and Release Detailed Design
|
||||
|
||||
| Build/release item | Design detail |
|
||||
|---|---|
|
||||
| Rust CI | `.github/workflows/ci.yml` runs cargo check/test and advisory clippy |
|
||||
| Flutter CI | `.github/workflows/ci.yml` runs Flutter pub get, analyze, and tests |
|
||||
| Supply chain | CI runs cargo-deny and license inventory checks |
|
||||
| iOS unsigned build | CI runs `flutter build ios --release --no-codesign` |
|
||||
| Audio benchmarks | `bench-advisory.yml` runs audio benchmarks and posts advisory evidence |
|
||||
| Platform packages | Public binary packaging/signing/notarization remains release-gated |
|
||||
|
||||
## 10. Verification Hook Design
|
||||
|
||||
| Module | SWE.4 unit hooks | SWE.5/SWE.6 integration hooks |
|
||||
|---|---|---|
|
||||
| Flutter services/widgets | Dart unit/widget tests under `apps/chanora_flutter/test/` | Widget/system demos and candidate device smoke |
|
||||
| Bridge | API compile/generation checks | Flutter-to-Rust command/event smoke |
|
||||
| Rust core | Cargo tests | Compatible-server lifecycle demo |
|
||||
| Protocol | DTO/error mapping tests | Protocol compatibility matrix and server demo |
|
||||
| State sync | Reducer tests | Snapshot/delta/reconnect integration evidence |
|
||||
| Audio | DSP/codec/PTT tests and benchmarks | Platform audio loopback/device demo |
|
||||
| Storage | Repository/encryption/keyring-disabled tests | Platform secure-storage audit |
|
||||
| Diagnostics | Redaction/export tests | User-initiated export inspection |
|
||||
| Release hooks | CI workflow validation | Release readiness record and artifact evidence |
|
||||
|
||||
## 11. Traceability to SAD
|
||||
|
||||
| SAD component | SDD modules |
|
||||
|---|---|
|
||||
| Flutter app shell | SDD-MOD-001 |
|
||||
| Flutter service layer | SDD-MOD-006, SDD-MOD-014 |
|
||||
| Flutter widget layer | SDD-MOD-002 through SDD-MOD-005 |
|
||||
| Bridge layer | SDD-MOD-007 |
|
||||
| Rust core | SDD-MOD-008 |
|
||||
| Protocol adapter | SDD-MOD-009 |
|
||||
| State sync | SDD-MOD-010 |
|
||||
| Audio subsystem | SDD-MOD-011 |
|
||||
| Storage | SDD-MOD-012 |
|
||||
| Diagnostics | SDD-MOD-013 |
|
||||
| Server resolver/prefetch | SDD-MOD-014 |
|
||||
| Release/platform architecture | SDD-MOD-015 |
|
||||
|
||||
## 12. Open Detailed-Design Risks
|
||||
|
||||
| Risk | Impact | Control |
|
||||
|---|---|---|
|
||||
| Detailed item IDs from historical SDD references are not reconstructed | Existing references such as `SDD-109` are not itemized in this baseline | Treat this as a DV baseline SDD and add strict item numbering later if required |
|
||||
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
|
||||
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
|
||||
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
|
||||
| Android runtime smoke is blocked when no device/emulator is attached | Android permission/audio/lifecycle paths cannot be claimed from Rust tests alone | Require `adb devices -l` and Android smoke evidence before closing Android verification claims |
|
||||
|
||||
## 13. DV Conclusion
|
||||
|
||||
This SWE.3 baseline is sufficient to remove the missing-SDD traceability gap for DV review and to feed SWE.4/SWE.5 verification plans. It does not close release evidence gaps or replace source-level tests.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user