Compare commits

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

Branch: docs/codebase-analysis-v2
2026-06-11 09:23:10 +09:00
106 changed files with 22299 additions and 6572 deletions
+124 -15
View File
@@ -25,15 +25,10 @@ jobs:
run: cargo check --workspace --locked
- name: cargo test --workspace
env:
# Storage tests must not hit the real OS keyring on CI:
# there is no D-Bus session available and the call would
# block. The runtime code carries the same toggle for
# headless / sandboxed environments.
CHANORA_DISABLE_KEYRING: "1"
run: cargo test --workspace --locked --no-fail-fast
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
continue-on-error: true
supply-chain:
name: cargo deny (licenses + advisories + bans + sources)
@@ -43,9 +38,6 @@ jobs:
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
# `licenses` enforces the DEC-020 license posture; the
# other three are minimal supply-chain hygiene per
# `docs/governance/legal-review-readiness.md` §5.
arguments: --workspace --all-features
license-inventory:
@@ -58,10 +50,6 @@ jobs:
- name: Install cargo-about
run: cargo install --locked --features cli cargo-about
- name: Regenerate inventory and compare
# Build the inventory in a temp file and diff against the
# committed copy. CI fails when the committed inventory is
# stale, forcing contributors to run the tool locally
# before opening a PR that touches the dependency tree.
run: |
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
diff docs/security/license-inventory.md /tmp/license-inventory.md \
@@ -80,9 +68,6 @@ jobs:
run: flutter pub get
- name: Regenerate Flutter license inventory and compare
env:
# Resolved by the wrapper from $HOME/sdks/flutter when
# not set; CI's subosito/flutter-action puts flutter on
# PATH but exports the SDK root under FLUTTER_ROOT.
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
run: |
./tools/dump_flutter_licenses.sh
@@ -136,3 +121,127 @@ jobs:
if: steps.silero-coreml.outputs.available == 'true'
working-directory: apps/chanora_flutter
run: flutter build ios --release --no-codesign
- name: xcodebuild archive verification
if: steps.silero-coreml.outputs.available == 'true'
working-directory: apps/chanora_flutter
run: |
xcodebuild archive \
-workspace ios/Runner.xcworkspace \
-scheme Runner \
-archive build/Runner.xcarchive \
CODE_SIGNING_ALLOWED=NO \
| xcpretty || { echo "::error::xcodebuild archive failed — see issue-history-analysis.md §4 'Xcode Archive vs build divergence'"; exit 1; }
android-build:
name: Android build (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-android
abi: arm64-v8a
- target: armv7-linux-androideabi
abi: armeabi-v7a
- target: x86_64-linux-android
abi: x86_64
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- name: Install cargo-ndk
run: cargo install --locked cargo-ndk
- name: Setup NDK
run: |
ANDROID_ROOT="/usr/local/lib/android/sdk"
SDKMANAGER="$ANDROID_ROOT/cmdline-tools/latest/bin/sdkmanager"
echo "y" | $SDKMANAGER "ndk;27.0.12077973"
echo "ANDROID_NDK_HOME=$ANDROID_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV"
- name: cargo ndk build
run: cargo ndk -t ${{ matrix.abi }} build --workspace --locked
windows-build:
name: Windows build
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo check --workspace
run: cargo check --workspace --locked
macos-build:
name: macOS build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo check --workspace
run: cargo check --workspace --locked
linux-multi-distro:
name: Linux build (${{ matrix.distro }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- distro: ubuntu
image: ubuntu:24.04
install: |
apt-get update
apt-get install -y curl build-essential pkg-config \
libasound2-dev libpulse-dev libdbus-1-dev libsdl2-dev libopus-dev
- distro: fedora
image: fedora:latest
install: |
dnf install -y curl gcc pkg-config \
alsa-lib-devel pulseaudio-libs-devel dbus-devel SDL2-devel opus-devel
- distro: arch
image: archlinux:latest
install: |
pacman -Syu --noconfirm
pacman -S --noconfirm curl base-devel pkg-config \
alsa-lib pulseaudio dbus sdl2 opus
container:
image: ${{ matrix.image }}
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: ${{ matrix.install }}
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: cargo check --workspace
run: cargo check --workspace
coverage:
name: cargo llvm-cov
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: System deps
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev libpulse-dev pkg-config \
libdbus-1-dev libsdl2-dev libopus-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
run: cargo install --locked cargo-llvm-cov
- name: Generate coverage
env:
CHANORA_DISABLE_KEYRING: "1"
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: lcov-report
path: lcov.info
Generated
+2
View File
@@ -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",
+2
View File
@@ -40,6 +40,8 @@ members = [
exclude = [
"apps/chanora_flutter",
"tools/protocol-probe",
"tools/audio-test",
]
[workspace.package]
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024-2026 Chanora Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Chanora Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
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
processingBackend = preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
if (appleVoiceProcessing) {
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vad,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.platform,
agc: rust.BridgeEffectOwner.platform,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
if (desktopWebrtcApm) {
: rust.BridgeAudioBackend.webrtcApm;
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
} else if (appleVoiceProcessing) {
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
aec = rust.BridgeEffectOwner.platform;
ns = rust.BridgeEffectOwner.platform;
agc = rust.BridgeEffectOwner.platform;
} else if (desktopWebrtcApm) {
final owner = rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
processingBackend = rust.BridgeAudioBackend.webrtcApm;
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
} else {
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
aec = rust.BridgeEffectOwner.platform;
ns = nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off;
agc = agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off;
}
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
processingBackend: processingBackend,
vadBackend: vad,
aec: rust.BridgeEffectOwner.platform,
ns: nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off,
agc: agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off,
aec: aec,
ns: ns,
agc: agc,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
@@ -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 {
@@ -738,129 +723,11 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
const SizedBox(height: 4),
// Android HW/SW selector.
if (Platform.isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) {
setState(() => _audioProcessing.preferHardware = s.first);
_notifyAudioConfig();
},
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
: 'Software mode applies the full WebRTC APM stage set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
AudioProcessingPanel(
config: _audioProcessing,
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: Platform.isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) {
setState(() => _audioProcessing.limiterEnabled = v);
_notifyAudioConfig();
},
),
// VAD backend.
const SizedBox(height: 8),
Text(
'Voice activity detection (VAD)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);
update: (mutation) {
setState(mutation);
_notifyAudioConfig();
},
),
@@ -2,9 +2,17 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard the user would bind a PTT key on.
bool get isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files.
// Centralize all platform checks here and update call sites to use these
// getters instead of raw Platform.isAndroid/isIOS/etc.
bool get _notWeb => !kIsWeb;
bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid);
bool get isAndroidHost => _notWeb && Platform.isAndroid;
bool get isIosHost => _notWeb && Platform.isIOS;
bool get isMacOsHost => _notWeb && Platform.isMacOS;
bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux);
@@ -7,9 +7,6 @@
// - VAD backend
// - platform audio-processing mode selection where available
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
@@ -22,27 +19,8 @@ import 'voice_platform.dart';
import 'voice_settings_controls.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
}
bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
bool get _isDesktopSileroVadHost {
if (kIsWeb) return false;
return Platform.isWindows || Platform.isLinux;
}
/// Result returned by [VoiceSettingsDialog].
/// Result returned by [VoiceSettingsDialog] when the user saves or
/// requests a key bind.
class VoiceSettingsResult {
const VoiceSettingsResult({
required this.mode,
@@ -57,7 +35,15 @@ class VoiceSettingsResult {
final rust.BridgeAudioProcessingConfig audioConfig;
}
/// Voice + audio processing settings dialog.
/// Dialog for configuring voice transmission and audio processing settings.
///
/// Surfaces transmit mode selection (continuous, PTT, voice-activity),
/// PTT release-tail slider, key-bind request, and a full audio processing
/// panel covering noise suppression, echo cancellation, AGC, HPF, and VAD
/// backend selection.
///
/// On mobile hosts, the voice-activity segment is hidden when no
/// Chanora-owned VAD pipeline is available (iOS, macOS, web).
class VoiceSettingsDialog extends StatefulWidget {
const VoiceSettingsDialog({
super.key,
@@ -70,6 +56,8 @@ class VoiceSettingsDialog extends StatefulWidget {
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
this.onInputDeviceChanged,
this.onOutputDeviceChanged,
});
final rust.BridgeTransmitMode initialMode;
@@ -81,6 +69,8 @@ class VoiceSettingsDialog extends StatefulWidget {
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
final ValueChanged<String?>? onInputDeviceChanged;
final ValueChanged<String?>? onOutputDeviceChanged;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
@@ -106,7 +96,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
rust.BridgeAudioProcessingConfig _buildConfig() {
return _audioProcessing.buildConfig(
base: widget.initialAudioConfig,
isAndroid: _isAndroid,
isAndroid: isAndroidHost,
);
}
@@ -189,91 +179,9 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const Divider(height: 24),
const VoiceSectionHeader('Audio processing'),
// Android HW/SW selector
if (_isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.preferHardware = s.first),
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
: 'Android software mode applies the full WebRTC APM control set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
// DSP toggles
const VoiceSubHeader('DSP stages'),
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
if (!_isIos &&
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) => setState(() => _audioProcessing.aecEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
AudioProcessingPanel(
config: _audioProcessing,
update: (mutation) => setState(mutation),
),
if (isTalkPowerBlocked(
@@ -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,
]),
);
});
});
}
+74
View File
@@ -0,0 +1,74 @@
# chanora_core
Top-level Rust API and orchestration layer for the Chanora client. Composes subsystem crates behind a stable, typed API consumed by `chanora_bridge`. Owns no protocol, audio, or storage logic directly.
## Architecture
Per SAD §7.2, `chanora_core` is the integration point:
- **`ChanoraSession`** — the primary public type. Owns at most one active server connection (DEC-006). Provides connect, disconnect, snapshot, audio lifecycle, PTT, bookmarks, and diagnostics methods.
- **Supervisor** — a per-connection tokio task that monitors connection health via a loss notifier and a watchdog probe, and auto-reconnects with exponential backoff (1 s → 60 s capped). Re-attaches the audio engine if it was running prior to the loss.
- **`SessionEvent`** — broadcast enum emitted on connect/lost/reconnecting/disconnected/audio-started/audio-stopped/voice-state/chat/route changes. Subscribers consume via `subscribe_events()`.
- **File transfer** — avatar/icon download routed through a cacache-backed blob cache with LRU eviction.
- **Channel join state machine** — reducer-based state tracking for voice channel joins, with optimistic commands, snapshot reconciliation, and error projection.
- **PTT controller** — platform input backend management, binding persistence, and release-tail timer wiring (SDD-088/094/096).
## Public API Summary
### Core types
| Type | Role |
|---|---|
| `ChanoraSession` | Top-level session handle; cloneable, thread-safe |
| `CoreError` | Unified error enum covering all subsystem errors |
| `SessionEvent` | Broadcast lifecycle event enum |
| `ConnectConfig` | Typed connection parameters |
| `NetworkState` | OS connectivity state enum |
### Key methods on `ChanoraSession`
- `new()` — construct an empty session (no I/O)
- `init_storage(dir)` — wire identity + bookmark stores
- `init_cache(dir)` — wire the blob cache for avatars/icons
- `connect(cfg)``ServerSnapshot` — dial a server (single-connection invariant)
- `disconnect()` — clean teardown including supervisor
- `is_connected()` — check connection state
- `snapshot()``ServerSnapshot` — refresh server state
- `client_profile(client_id)` — rich profile for one client
- `voice_join(channel_id, password)` / `voice_leave()` — audio lifecycle
- `start_audio(cfg)` — initialize audio subsystem
- `set_input_device(id)` / `set_output_device(id)` — device selection
- `set_output_gain(gain)` / `set_client_volume(client_id, volume)` — volume control
- `set_transmit_mode(mode)` / `get_transmit_mode()` — transmit mode
- `set_hard_mute(muted)` — hard-mute clamp
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail config
- `set_ptt(active)` / `set_ptt_binding(binding)` / `ptt_descriptor()` — PTT control
- `send_text_message(message, target)` — chat
- `move_to_channel(id, password)` / `set_self_muted(input, output)` — channel + mute
- `subscribe_events()` — broadcast receiver for `SessionEvent`
- `drain_protocol_events()` / `protocol_events_snapshot()` — protocol event access
- `export_diagnostics()` — redacted diagnostic bundle (includes network stats)
- `audio_stats()` — audio subsystem telemetry
- `network_diagnostics_summary()` — network statistics
- `prefetch_server(host)` — warm server-address resolution
- `set_audio_processing_config(cfg)` / `get_audio_processing_config()` — audio DSP config
- `set_audio_debug_wav_dump(enabled)` — WAV dump toggle
- `set_vad_model_path(path)` — Silero model path
- `transmit_selector()` / `release_tail_timer()` — subsystem accessors
### Re-exports
Re-exports selected types from `chanora_protocol`, `chanora_audio`, `chanora_storage`, and `chanora_diagnostics` so the bridge only depends on `chanora_core`.
## Platform notes
- iOS/macOS-specific methods (`ios_handle_route_change`, `ios_handle_interruption_began`, etc.) are gated behind `cfg(target_os = "ios" | "macos")` inside method bodies.
- Android-specific reconnect paths are similarly gated.
- The crate itself compiles on all targets; platform-specific code is runtime- or cfg-gated.
## Invariants
- Single active connection at runtime (DEC-006)
- `tsclientlib` types never cross out of `chanora_protocol` (SAD-067)
- Secret material never lands in non-secret storage (DEC-013.2)
- Audio engine construction failure preserves the previous engine state
+516 -2
View File
@@ -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");
}
}
}
+21 -25
View File
@@ -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!(
+66
View File
@@ -0,0 +1,66 @@
# chanora_audio
Real-time audio subsystem: capture, Opus encoding/decoding, voice rendering, PTT gating, and audio processing. Promoted from `poc/audio-capture-playback-spike`.
## Architecture
### Engine
- **`AudioEngine`** — the primary type. Starts a platform audio backend (capture + playback), wires an `AudioTransmitGate` for PTT gating, and feeds encoded Opus frames to the protocol layer via `voice_out`. Inbound voice packets are decoded and mixed by `tsclientlib::audio::AudioHandler` and pulled by the platform output callback at 48 kHz stereo.
### Platform backends (cfg-gated)
| Target | Backend | Notes |
|---|---|---|
| Android | Oboe (via `android_voice_unit`) | Requires `ndk_context` before start |
| iOS/macOS | Apple VoiceProcessingIO (`ios_voice_unit`) | Platform AEC/AGC/NS, route-change handling |
| Linux | SDL (`sdl_output`) | PulseAudio/ALSA via SDL |
| Other desktop | cpal | Fallback |
### Key modules
- **`audio_processing`** — P1 audio processing config, stats, route policy, effect ownership (Platform/Sonora/WebRTC APM)
- **`opus_voice`** — 20 ms / 48 kHz mono Opus encode/decode via `audiopus`
- **`transmit_mode`** — `TransmitMode` enum: Ptt, Continuous, VoiceActivity
- **`transmit_selector`** — `TransmitModeSelector` combining mode, hard-mute, PTT gate, permission gate, and in-channel state
- **`ptt`** — `AudioTransmitGate` (atomic bool), `PttCapabilityLevel`, `PttBackendDescriptor`
- **`ptt_backends`** — platform PTT backends: `DesktopPttBackend` (Linux portal), `FocusedPttBackend` (in-app fallback)
- **`release_tail`** — `ReleaseTailTimer` for configurable PTT release delay (default 200 ms, max 500 ms)
- **`vad`** — Voice-activity detection: Silero ONNX (desktop), WebRTC fallback, energy debug
- **`voice_render`** — mixes per-client decoded f32 PCM into the output buffer
- **`debug_wav`** — optional WAV file dump for diagnostics (DIAG_002/003)
- **`mobile_voice_backend`** — shared mobile voice-unit lifecycle abstraction
- **`frame`** — frame-aligned buffer utilities
## Public API Summary
### Types
| Type | Role |
|---|---|
| `AudioEngine` | Start/stop audio, set gain/mute/volume, read stats |
| `AudioEngineConfig` | Capture/playback device selection, PTT initial state, processing config |
| `AudioDeviceInfo` / `AudioDeviceList` | Device enumeration |
| `AudioTransmitGate` | Atomic PTT gate |
| `TransmitMode` / `TransmitModeSelector` | Mode selection with hard-mute clamp |
| `ReleaseTailTimer` | Configurable release delay (SDD-096) |
| `PttBinding` / `PttInputClass` | PTT key binding types |
| `PttBackendDescriptor` / `PttCapabilityLevel` | Capability query |
| `AudioProcessingConfig` / `AudioProcessingStats` | P1 processing control and telemetry |
| `AudioRoute` | Speaker/Earpiece/Wired/Bluetooth enum |
| `AudioEffects` | Effect toggles (AEC/AGC/NS/HPF), all enabled by default (DEC-007..010) |
| `AudioError` | Typed error catalogue |
### Key functions
- `AudioEngine::start_with_gate(cfg, voice_out, voice_in, gate)` — construct and start
- `AudioEngine::stop()` — tear down
- `list_audio_devices()` — enumerate available input/output devices
- `select_ptt_backend()` — choose the best PTT backend for the current platform
## Platform notes
- Android requires `initChanoraContext` (NDK context) before engine start.
- iOS/macOS uses VoiceProcessingIO for platform AEC/AGC/NS in the default route.
- Desktop can use Silero ONNX VAD when the model file is available.
- `bench_seam` is exposed (`#[doc(hidden)]`) for criterion benchmarks on non-mobile targets.
@@ -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
+515
View File
@@ -0,0 +1,515 @@
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use cpal::traits::StreamTrait;
use cpal::{SampleFormat, SizedSample};
use tracing::{debug, error, warn, info};
use chanora_protocol::OutPacket;
use audiopus::coder::Encoder as OpusEncoder;
use crate::AudioError;
use super::SAMPLE_RATE;
use super::FRAME_SAMPLES;
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
pub(super) fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: tokio::sync::mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<cpal::Stream, AudioError> {
let in_cfg = in_dev
.default_input_config()
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
let in_sample_rate = in_cfg.sample_rate();
let in_channels = in_cfg.channels() as usize;
let in_format = in_cfg.sample_format();
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
}
#[cfg(not(target_os = "windows"))]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
}
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
let capture_state = Arc::new(Mutex::new(CaptureState::new(
opus_enc,
in_sample_rate,
in_channels,
mic_gain,
crate::opus_voice::start_out_packet_worker(
voice_out_tx,
frames_sent.clone(),
"cpal-capture",
)?,
transmit_active,
voice_activity_selector,
audio_processing_config,
silero_vad_worker,
audio_processing_stats,
)));
let stream = match in_format {
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
other => {
return Err(AudioError::StreamConfig(format!(
"unsupported input format: {other:?}"
)))
}
};
Ok(stream)
}
pub(super) struct CaptureState {
encoder: OpusEncoder,
pub(super) pcm_accum: Vec<f32>,
pub(super) pending_10ms: [f32; crate::frame::FRAME_10MS_SAMPLES],
pub(super) pending_10ms_len: usize,
pub(super) capture_frame_seq: u64,
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
resample_pos: f64,
resample_last: f32,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
silero_model_epoch: u64,
current_vad_backend: crate::VadBackend,
fallback_warned_backend: Option<crate::VadBackend>,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
mono_scratch: Vec<f32>,
frame_scratch: Vec<f32>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
last_level_emit: std::time::Instant,
}
impl CaptureState {
pub(super) fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
transmit_active: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Self {
Self {
encoder,
in_sample_rate,
in_channels,
mic_gain,
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
resample_pos: 0.0,
resample_last: 0.0,
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker,
silero_model_epoch: crate::vad::silero_model_epoch(),
current_vad_backend: crate::VadBackend::Disabled,
fallback_warned_backend: None,
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
pending_10ms: [0.0; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
mono_scratch: Vec::with_capacity(4096),
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
audio_processing_stats,
last_level_emit: std::time::Instant::now()
.checked_sub(LEVEL_METER_INTERVAL)
.unwrap_or_else(std::time::Instant::now),
}
}
pub(super) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
let in_channels = self.in_channels;
let mic_gain = self.mic_gain;
self.mono_scratch.clear();
let frame_count = buf.len() / in_channels.max(1);
self.mono_scratch.reserve(frame_count);
for frame in buf.chunks(in_channels) {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
self.mono_scratch.push(sum / frame.len() as f32);
}
let now = std::time::Instant::now();
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
self.last_level_emit = now;
self.audio_processing_stats
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
}
if mic_gain != 1.0 {
for s in &mut self.mono_scratch {
*s *= mic_gain;
}
}
let vad_start_offset = self.pcm_accum.len();
if self.in_sample_rate == SAMPLE_RATE {
let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum);
dst.extend_from_slice(src);
} else {
let mono = std::mem::take(&mut self.mono_scratch);
self.resample_into_accum(&mono);
self.mono_scratch = mono;
}
self.process_pending_vad_frames(vad_start_offset);
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
}
while self.pcm_accum.len() >= FRAME_SAMPLES {
let frame = &mut self.frame_scratch;
frame.clear();
frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES));
for s in frame.iter_mut() {
if *s > 1.0 {
*s = 1.0;
} else if *s < -1.0 {
*s = -1.0;
}
}
match self
.encoder
.encode_float(&frame[..], &mut self.opus_out[..])
{
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.opus_out,
len,
|| {
warn!(
target: "chanora_audio",
"voice_out queue full; dropping frame"
);
},
|| {
warn!(target: "chanora_audio", "voice_out closed; stopping send");
},
);
}
Err(e) => {
error!(target: "chanora_audio", error = %e, "opus encode failed");
}
}
}
}
pub(super) fn process_pending_vad_frames(&mut self, start_offset: usize) {
let mut offset = start_offset.min(self.pcm_accum.len());
while offset < self.pcm_accum.len() {
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(self.pcm_accum.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&self.pcm_accum[offset..offset + take]);
self.pending_10ms_len += take;
offset += take;
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
let frame = self.pending_10ms;
self.process_10ms_capture_frame(&frame);
self.pending_10ms_len = 0;
}
}
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
self.fallback_warned_backend = Some(failed_backend);
}
fn sync_vad_backend(&mut self, voice_activity_mode: bool, vad_backend: crate::VadBackend) {
if !voice_activity_mode {
self.current_vad_backend = crate::VadBackend::Disabled;
self.fallback_warned_backend = None;
self.audio_processing_stats.set_vad_fallback_active(false);
return;
}
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed =
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
if vad_backend == self.current_vad_backend && !silero_changed {
return;
}
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.fallback_warned_backend = None;
self.vad_state.reset();
match vad_backend {
crate::VadBackend::SileroOnnx => {
let worker_available = self
.silero_vad_worker
.try_lock()
.map(|worker| worker.is_some())
.unwrap_or(false);
if worker_available {
self.audio_processing_stats.set_vad_fallback_active(false);
} else {
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
self.audio_processing_stats.set_vad_fallback_active(true);
}
}
crate::VadBackend::WebrtcVad => {
self.audio_processing_stats.set_vad_fallback_active(false);
}
crate::VadBackend::EnergyDebug => {
self.audio_processing_stats.set_vad_fallback_active(true);
}
crate::VadBackend::Disabled => {
self.audio_processing_stats.set_vad_fallback_active(false);
}
}
}
pub(super) fn process_10ms_capture_frame(&mut self, frame: &[f32; crate::frame::FRAME_10MS_SAMPLES]) {
let input_dbfs = crate::frame::dbfs(frame);
let (vad_backend, vad_hangover) = self
.audio_processing_config
.try_lock()
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
.unwrap_or((
crate::VadBackend::WebrtcVad,
crate::voice_activity::VAD_HANGOVER_MS,
));
let voice_activity_mode = self
.voice_activity_selector
.as_ref()
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
.unwrap_or(false);
if voice_activity_mode {
self.sync_vad_backend(true, vad_backend);
self.vad_state.configure(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
} else {
self.sync_vad_backend(false, vad_backend);
}
let (vad_probability, gate_open, used_fallback_vad) = if voice_activity_mode {
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
let capture_seq = self.capture_frame_seq;
let mut used_fallback_vad = false;
let vad = match vad_backend {
crate::VadBackend::Disabled => crate::vad::VadOutput {
probability: 1.0,
speech: true,
},
crate::VadBackend::SileroOnnx => {
let worker_output = {
let guard = self.silero_vad_worker.try_lock().ok();
guard.and_then(|guard| {
let worker = guard.as_ref()?;
if worker.try_send(capture_seq, frame) && !worker.is_stale(capture_seq)
{
let p = worker.latest_probability();
Some(crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
})
} else {
None
}
})
};
if let Some(output) = worker_output {
output
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
frame,
)
}
}
crate::VadBackend::WebrtcVad | crate::VadBackend::EnergyDebug => {
used_fallback_vad = vad_backend == crate::VadBackend::EnergyDebug;
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, frame)
}
};
(
vad.probability,
self.vad_state.update(vad.speech),
used_fallback_vad,
)
} else {
(0.0, false, false)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
let vad_active = voice_activity_mode && gate_open;
if let Some(selector) = &self.voice_activity_selector {
selector.set_voice_activity_open(vad_active);
}
self.audio_processing_stats.update_capture(
input_dbfs,
input_dbfs,
vad_probability,
vad_active,
self.transmit_active.load(Ordering::Relaxed),
);
self.audio_processing_stats
.record_capture_frame(frame.iter().all(|sample| sample.abs() <= 0.000_001));
}
fn resample_into_accum(&mut self, mono: &[f32]) {
if mono.is_empty() {
return;
}
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
let mut pos = self.resample_pos;
while pos < mono.len() as f64 {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let a = if i <= 0 {
self.resample_last
} else {
mono[(i - 1) as usize]
};
let b = if i < mono.len() as isize {
mono[i as usize]
} else {
a
};
self.pcm_accum
.push((a as f64 + frac * (b - a) as f64) as f32);
pos += ratio;
}
self.resample_pos = pos - mono.len() as f64;
self.resample_last = *mono.last().unwrap();
}
}
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
impl ToF32 for f32 {
fn to_f32_sample(self) -> f32 {
self
}
}
impl ToF32 for i16 {
fn to_f32_sample(self) -> f32 {
f32::from(self) / f32::from(i16::MAX)
}
}
impl ToF32 for u16 {
fn to_f32_sample(self) -> f32 {
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
}
}
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
state: Arc<Mutex<CaptureState>>,
) -> Result<cpal::Stream, AudioError>
where
T: SizedSample + ToF32 + Send + 'static,
{
let stream = device
.build_input_stream(
*config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
s.ingest(data);
},
move |e| {
error!(target: "chanora_audio", error = %e, "input stream error");
},
None,
)
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
Ok(stream)
}
#[doc(hidden)]
pub mod bench_seam {
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
use tokio::sync::mpsc;
pub struct CaptureBenchHandle {
pub(crate) state: CaptureState,
_rx: mpsc::Receiver<OutPacket>,
transmit_active: Arc<AtomicBool>,
}
impl CaptureBenchHandle {
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
let encoder =
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
let (tx, rx) = mpsc::channel::<OutPacket>(64);
let transmit_active = Arc::new(AtomicBool::new(true));
let frames_sent = Arc::new(AtomicU32::new(0));
let voice_out_tx =
crate::opus_voice::start_out_packet_worker(tx, frames_sent, "cpal-bench")
.expect("start_out_packet_worker");
let state = CaptureState::new(
encoder,
in_sample_rate,
in_channels,
1.0,
voice_out_tx,
transmit_active.clone(),
None,
Arc::new(std::sync::Mutex::new(
crate::AudioProcessingConfig::default(),
)),
Arc::new(std::sync::Mutex::new(None)),
Arc::new(crate::SharedAudioProcessingStats::default()),
);
Self {
state,
_rx: rx,
transmit_active,
}
}
#[inline]
pub fn ingest_f32(&mut self, buf: &[f32]) {
self.state.ingest(buf);
}
pub fn set_transmit_active(&self, active: bool) {
self.transmit_active
.store(active, std::sync::atomic::Ordering::Relaxed);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use cpal::traits::StreamTrait;
use cpal::SampleFormat;
use tracing::{error, warn};
use tsclientlib::audio::AudioHandler;
use crate::AudioError;
use super::SessionAudioId;
use super::SAMPLE_RATE;
pub(super) fn build_output_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
dev_sample_rate: u32,
dev_channels: usize,
) -> Result<cpal::Stream, AudioError>
where
T: cpal::SizedSample + FromF32 + Send + 'static,
{
let resample_ratio = SAMPLE_RATE as f64 / dev_sample_rate as f64;
let same_rate = dev_sample_rate == SAMPLE_RATE;
let resample_state: Arc<Mutex<PlaybackResampleState>> =
Arc::new(Mutex::new(PlaybackResampleState {
pos: 0.0,
last_l: 0.0,
last_r: 0.0,
}));
let mut scratch: Vec<f32> = Vec::with_capacity(8192);
let mut last_slow_warn = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(2))
.unwrap_or_else(std::time::Instant::now);
let stream = device
.build_output_stream(
*config,
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
let cb_start = std::time::Instant::now();
let muted = output_muted.load(Ordering::Relaxed);
let dev_frames = out.len() / dev_channels.max(1);
let src_frames = if same_rate {
dev_frames
} else {
((dev_frames as f64 * resample_ratio).ceil() as usize) + 2
};
let needed = src_frames * 2;
if scratch.len() < needed {
scratch.resize(needed, 0.0);
}
scratch[..needed].fill(0.0);
{
let mut h = handler.lock().unwrap_or_else(|e| e.into_inner());
h.fill_buffer(&mut scratch[..needed]);
}
if muted {
for dst in out.iter_mut() {
*dst = T::from_f32_sample(0.0);
}
} else {
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
if same_rate && dev_channels == 2 {
for (dst, s) in out.iter_mut().zip(scratch[..needed].iter().copied()) {
*dst = T::from_f32_sample(s * gain);
}
} else {
let mut state = resample_state.lock().unwrap_or_else(|e| e.into_inner());
let mut pos = state.pos;
let mut last_l = state.last_l;
let mut last_r = state.last_r;
for frame_idx in 0..dev_frames {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let (a_l, a_r) = if i <= 0 {
(last_l, last_r)
} else {
let idx = ((i - 1) as usize) * 2;
(scratch[idx], scratch[idx + 1])
};
let i_usize = i.max(0) as usize;
let (b_l, b_r) = if i_usize < src_frames {
let idx = i_usize * 2;
(scratch[idx], scratch[idx + 1])
} else {
(a_l, a_r)
};
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
let base = frame_idx * dev_channels;
if dev_channels == 1 {
out[base] = T::from_f32_sample((l + r) * 0.5);
} else {
out[base] = T::from_f32_sample(l);
if dev_channels >= 2 {
out[base + 1] = T::from_f32_sample(r);
}
for c in 2..dev_channels {
out[base + c] = T::from_f32_sample(0.0);
}
}
pos += resample_ratio;
}
let consumed = pos.floor() as usize;
state.pos = pos - consumed as f64;
if consumed > 0 && consumed <= src_frames {
let idx = (consumed - 1) * 2;
last_l = scratch[idx];
last_r = scratch[idx + 1];
state.last_l = last_l;
state.last_r = last_r;
}
}
}
let elapsed = cb_start.elapsed();
let period_us = (dev_frames as u64 * 1_000_000) / dev_sample_rate as u64;
if elapsed.as_micros() as u64 > period_us / 2
&& last_slow_warn.elapsed() > std::time::Duration::from_secs(1)
{
last_slow_warn = std::time::Instant::now();
warn!(
target: "chanora_audio",
callback_us = elapsed.as_micros() as u64,
period_us,
dev_frames,
"output callback exceeded half the period budget — possible underrun cause"
);
}
},
move |e| {
error!(target: "chanora_audio", error = %e, "output stream error");
},
None,
)
.map_err(|e| {
error!(
target: "chanora_audio",
error = %e,
requested_channels = config.channels,
requested_sample_rate = config.sample_rate,
"build_output_stream FAILED"
);
AudioError::Backend(format!("build_output_stream: {e}"))
})?;
Ok(stream)
}
struct PlaybackResampleState {
pos: f64,
last_l: f32,
last_r: f32,
}
pub(super) trait FromF32 {
fn from_f32_sample(v: f32) -> Self;
}
impl FromF32 for f32 {
fn from_f32_sample(v: f32) -> Self {
v
}
}
impl FromF32 for i16 {
fn from_f32_sample(v: f32) -> Self {
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
}
}
impl FromF32 for u16 {
fn from_f32_sample(v: f32) -> Self {
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
(s + i32::from(i16::MAX) + 1) as u16
}
}
+2 -60
View File
@@ -16,56 +16,12 @@ pub const FRAME_10MS_SAMPLES: usize = 480;
/// Samples in one 20 ms mono frame at 48 kHz.
pub const FRAME_20MS_SAMPLES: usize = 960;
/// 10 ms, 48 kHz, mono f32 processing frame.
#[derive(Debug, Clone, PartialEq)]
pub struct AudioFrame10ms {
/// Samples normalized to `[-1.0, 1.0]`.
pub samples: [f32; FRAME_10MS_SAMPLES],
}
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
#[derive(Debug, Clone, PartialEq)]
pub struct AudioFrame20ms {
/// Samples normalized to `[-1.0, 1.0]`.
pub samples: [f32; FRAME_20MS_SAMPLES],
}
impl AudioFrame20ms {
/// Convert one 20 ms frame into two 10 ms processing frames.
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
let mut first = [0.0; FRAME_10MS_SAMPLES];
let mut second = [0.0; FRAME_10MS_SAMPLES];
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
(
AudioFrame10ms { samples: first },
AudioFrame10ms { samples: second },
)
}
}
impl AudioFrame10ms {
/// Merge two 10 ms processing frames back into the 20 ms network
/// cadence used by the existing Opus path.
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
let mut samples = [0.0; FRAME_20MS_SAMPLES];
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
AudioFrame20ms { samples }
}
/// Compute RMS dBFS for diagnostics and fallback VAD.
pub fn dbfs(&self) -> f32 {
dbfs(&self.samples)
}
}
/// Convert i16 PCM to normalized f32 PCM.
/// Convert i16 PCM sample to normalized f32 PCM (-1.0 to 1.0).
pub fn i16_to_f32(sample: i16) -> f32 {
sample as f32 / i16::MAX as f32
}
/// Convert normalized f32 PCM to saturated i16 PCM.
/// Convert normalized f32 PCM to saturated i16 PCM (clamps to [-1.0, 1.0]).
pub fn f32_to_i16(sample: f32) -> i16 {
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
@@ -84,18 +40,4 @@ pub fn dbfs(samples: &[f32]) -> f32 {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_merge_preserves_samples() {
let mut samples = [0.0; FRAME_20MS_SAMPLES];
for (i, s) in samples.iter_mut().enumerate() {
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
}
let original = AudioFrame20ms { samples };
let (a, b) = original.split();
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
}
}
+4 -4
View File
@@ -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 {
+1 -1
View File
@@ -174,7 +174,7 @@ impl AudioCallback for TsPlaybackCallback {
// in the cpal path, but the upstream design has shipped
// this way for years.
{
let mut data = self.handler.lock().unwrap();
let mut data = self.handler.lock().unwrap_or_else(|e| e.into_inner());
let _removed_ids = data.fill_buffer(buffer);
// `_removed_ids` is the list of clients whose stream the
// handler just finished draining. We could publish that
+2 -1
View File
@@ -89,7 +89,8 @@ unsafe fn resolve_symbol(name: &'static [u8]) -> Option<*mut c_void> {
}
}
/// 16 kHz detector backed by Swift `SileroCoreML.SileroVAD`.
/// 16 kHz detector backed by Swift Silero CoreML VAD.
/// Processes 16 kHz frames and outputs speech probability.
pub struct AppleCoreMlVad {
handle: *mut c_void,
symbols: AppleSileroSymbols,
+13 -8
View File
@@ -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);
}
+2 -2
View File
@@ -82,7 +82,7 @@ struct RecordingLayer {
impl RecordingLayer {
fn snapshot(&self) -> Vec<Captured> {
self.records.lock().unwrap().clone()
self.records.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
@@ -118,7 +118,7 @@ where
target: event.metadata().target().to_string(),
field_names: names.0,
};
self.records.lock().unwrap().push(captured);
self.records.lock().unwrap_or_else(|e| e.into_inner()).push(captured);
}
}
+3
View File
@@ -36,3 +36,6 @@ ndk-context = "0.1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
[dev-dependencies]
serde_json = "1"
+80
View File
@@ -0,0 +1,80 @@
# chanora_bridge
Typed Flutter/Rust bridge — schema-controlled DTOs for commands, results, and events. Backed by `flutter_rust_bridge` 2.x per DEC-014.
## Architecture
- **`api` module** — all public functions exposed to Dart. Each function runs on a shared tokio runtime and delegates to `chanora_core::ChanoraSession`. Input/output types are owned primitives or `String`s — no backend types cross the boundary (SAD-067, SDD-079).
- **`frb_generated`** — auto-generated `flutter_rust_bridge` glue. Contains `unsafe` for the FFI boundary; hand-written code must not use `unsafe`.
- **`android_init`** (Android only) — NDK context initialization
- **`permission_jni`** (Android only) — JNI hook for Android permission state changes (SDD-106)
### DTO pattern
Every Dart-facing type is a `Bridge*` DTO with primitive fields. `From` impls convert between bridge DTOs and `chanora_core` types. Most types do not carry `serde` derives — FRB generates its own SSE encoders/decoders.
### Event streaming
`BridgeEvent` enum is streamed to Dart via FRB's `StreamSink`. Events include: Connected, Disconnected, Lost, Reconnecting, ChatMessage, VoiceState, AudioStarted/Stopped, ClientJoined/Left/Moved/Updated, ChannelAdded/Removed/Updated, PttCapability, PermissionState, ServerActivity, InterruptionState, AudioRouteChanged.
## Public API Summary
### Commands (api.rs)
| Command | Description |
|---|---|
| `bridge_init()` | One-time init: logging, panic hook |
| `connect(host, nickname, password)` | Connect to a server |
| `disconnect()` | Clean disconnect |
| `snapshot()` | Refresh server state |
| `client_profile(client_id)` | Rich profile for one client |
| `is_connected()` | Connection check |
| `prefetch_server(host)` | Warm server resolution |
| `voice_join(channel_id, password)` | Join voice channel |
| `voice_leave()` | Leave voice channel |
| `set_transmit_mode(mode)` | Ptt/Continuous/VoiceActivity |
| `get_transmit_mode()` | Read current mode |
| `set_hard_mute(muted)` | Hard-mute clamp |
| `set_ptt(active)` | Manual PTT press/release |
| `set_ptt_binding(input_class, platform_key)` | Bind a PTT key |
| `ptt_descriptor()` | Current PTT capability |
| `get_ptt_binding()` | Persisted PTT binding |
| `set_release_tail_ms(ms)` | Release-tail config |
| `get_release_tail_ms()` | Read release-tail |
| `move_to_channel(channel_id, password)` | Move to a channel |
| `set_input_muted(muted)` / `set_output_muted(muted)` | Server-side mute |
| `set_output_gain(gain)` | Master volume |
| `set_client_volume(client_id, volume)` | Per-client volume |
| `send_chat_message(message, target)` | Send text |
| `set_audio_processing_config(config)` | P1 audio processing |
| `get_audio_processing_config()` | Read P1 config |
| `audio_processing_stats()` | P1 telemetry |
| `enable_audio_debug_wav_dump(enabled)` | WAV dump toggle |
| `set_vad_model_path(path)` | Silero model path |
| `set_input_device(id)` / `set_output_device(id)` | Device selection |
| `export_diagnostics()` | Redacted export bundle (includes network stats) |
| `audio_stats()` | Audio subsystem statistics |
| `input_level_stream()` | Mic level metering stream |
| `events_stream()` | Bridge event stream |
| `log_file_path_str()` | Log file path for platform |
| `init_storage()` / `init_cache()` | Storage/cache initialization |
| `set_ios_voice_processing_mode(mode)` | iOS audio processing mode |
| `set_audio_output_route(route)` | Audio output route selection |
| `list_audio_devices()` | Enumerate audio devices |
| `list_bookmarks()` / `add_bookmark` / `update_bookmark` / `delete_bookmark` | Bookmark CRUD |
| `download_avatar(hash, uid)` / `download_icon(id)` | Avatar/icon download |
| `clear_file_cache()` / `file_cache_size()` | Cache management |
| `handle_route_change(route)` | iOS audio route change |
| `handle_media_services_reset_with_route(route_class)` | iOS media reset |
| `handle_interruption_began()` / `handle_interruption_ended(should_resume)` | iOS interruption |
| `lifecycle_event(state)` | Platform lifecycle |
### Bridge DTOs
`BridgeSnapshot`, `BridgeChannel`, `BridgeClient`, `BridgeClientProfile`, `BridgeAudioStats`, `BridgeAudioProcessingConfig`, `BridgeAudioProcessingStats`, `BridgeAudioRoute`, `BridgeTransmitMode`, `BridgePttInputClass`, `BridgePttDescriptor`, `BridgePttBinding`, `BridgeMessageTarget`, `BridgeBookmark`, `PermissionStateKind`, `BridgeError`.
## Platform notes
- Cannot use `#![forbid(unsafe_code)]` because FRB-generated glue legitimately uses `unsafe` for the FFI boundary.
- Android: includes `android_init` and `permission_jni` modules gated behind `cfg(target_os = "android")`.
- iOS: route-change and interruption handlers are synchronous (`#[frb(sync)]`), dispatched to the tokio runtime via an ordered channel.
+21 -1
View File
@@ -603,6 +603,8 @@ pub async fn connect(
nickname: String,
password: String,
) -> Result<BridgeSnapshot, BridgeError> {
let nickname = chanora_core::validate_nickname(&nickname)
.map_err(|e| BridgeError::InvalidCommand(e.to_string()))?;
let cfg = chanora_core::ConnectConfig {
address: host,
nickname,
@@ -837,6 +839,15 @@ pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
Ok(())
}
/// Read the current hard-mute state. Returns `true` when the audio
/// engine is clamped and transmits nothing regardless of mode.
pub async fn is_hard_muted() -> bool {
runtime()
.spawn(async { session().hard_mute() })
.await
.unwrap_or(false)
}
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
/// the bridge never carries raw key codes.
#[derive(Debug, Clone, Copy)]
@@ -999,6 +1010,15 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
Ok(())
}
/// Read the current master output gain. Returns `1.0` (unity) when
/// audio is not started.
pub async fn get_output_gain() -> f32 {
runtime()
.spawn(async { session().output_gain().await })
.await
.unwrap_or(1.0)
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
@@ -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(())
}
+316 -1
View File
@@ -51,11 +51,15 @@ use thiserror::Error;
/// Errors raised at the bridge boundary. Production code must keep
/// these user-safe — no secrets, no protocol details, no path
/// information beyond what the redaction policy permits.
#[derive(Debug, Error, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Error, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum BridgeError {
/// The caller submitted a malformed command DTO.
#[error("invalid command: {0}")]
InvalidCommand(String),
// TODO(refactor): DnsFailed and ServerRejected mirror ProtocolError variants
// in chanora_protocol. These cannot be unified without changing the public FFI
// API (flutter_rust_bridge generates Dart types from these). Revisit only if
// the bridge error types are being reworked.
/// Hostname resolution failed. Distinct from `Connection` so the
/// UI can show a meaningful "Server not found" message.
#[error("dns: could not resolve '{host}': {reason}")]
@@ -94,6 +98,12 @@ pub enum BridgeError {
Unmapped(String),
}
impl BridgeError {
fn unmapped_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
BridgeError::Unmapped(format!("{ctx}: {e}"))
}
}
impl From<chanora_core::CoreError> for BridgeError {
fn from(e: chanora_core::CoreError) -> Self {
match e {
@@ -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");
}
}
}
+39
View File
@@ -0,0 +1,39 @@
# chanora_cache
Disposable content-addressed blob cache for avatar and icon files. Wraps `cacache` for crash safety and integrity verification. Separated from `chanora_storage` because cache owns reconstructible, disposable blob data with different durability and backup semantics.
## Architecture
- **`BlobCache`** — async blob store backed by cacache's content-v2 / index-v2 on-disk layout.
- Keys are protocol identifiers prefixed by type: `av_<32-char-hex>` for avatars (MD5), `ic_<decimal>` for icons (CRC32).
- Cacache handles dedup and SSRI integrity verification on every read.
- Corrupt entries are automatically removed on read failure.
- LRU eviction by timestamp when total size exceeds the configured cap.
## Public API Summary
### Types
| Type | Role |
|---|---|
| `BlobCache` | Content-addressed blob cache |
| `BlobCacheError` | Io, InvalidKey |
### Key methods on `BlobCache`
- `new(cache_dir, max_bytes)` — create or open the cache. `max_bytes = 0` disables eviction.
- `put(prefix, key, data)` — store a blob (async)
- `get(prefix, key)``Option<Vec<u8>>` — read a blob, with integrity check (async)
- `remove(prefix, key)` — delete a specific blob (async)
- `clear()` — delete all blobs (async)
- `total_size()``u64` — sum of all blob sizes (async)
- `evict()` — remove oldest entries until under `max_bytes` cap (async)
### Constants
- `PREFIX_AVATAR` = `"av_"` — avatar key prefix
- `PREFIX_ICON` = `"ic_"` — icon key prefix
## Key validation
Avatar keys must be exactly 32 hex characters. Icon keys must be non-empty decimal digits. Unknown prefixes are rejected. This prevents malformed entries from polluting the cache.
+17 -8
View File
@@ -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))?
}
}
+80
View File
@@ -0,0 +1,80 @@
# chanora_diagnostics
Application diagnostics: log redaction, in-memory log capture, and user-initiated diagnostic export. Per DEC-016, export is **user-initiated only**; there is no automatic upload.
## Architecture
### Redaction policy
`Redactor` applies the production policy (REDACT-TC-001..010):
1. Known-secret registry — substring match → `[REDACTED]`
2. `$HOME` prefix → `[home]`
3. IPv4 addresses → `[ip]`
4. IPv6 addresses → `[ip]`
5. Email-shaped strings → `[email]`
6. Long opaque tokens (base64 ≥32 chars, ≥75% alnum) → `[token]`
### PTT sanitiser
`PttSanitizer<L>` — a `tracing-subscriber` Layer decorator that drops any record containing field names from the banned list (`key_code`, `scan_code`, `virtual_key`, `keysym`, etc.) per DEC-027 / REDACT-PTT-001..006. Allocation-free on the success path.
### Log capture
`InMemoryLogSink` — bounded ring buffer that passes every line through the redactor before storing. Capacity differs by build: 4096 lines (debug), 256 lines (release) per SRS-122.
### Event recorder
`ProtocolEventRecorder` — ring buffer of protocol-level events (connect, disconnect, reconnect, snapshot changes, channel joins) for diagnostic export and state-sync replay verification (SRS-097/098).
### Export
`DiagnosticExport` — serialisable bundle containing:
- Client metadata (version, platform)
- Redacted recent logs
- Known-secret count (values never exported)
- Optional Android audio diagnostics YAML
- Optional network diagnostics summary
- Protocol event trace
## Public API Summary
### Types
| Type | Role |
|---|---|
| `Redactor` | Production redaction policy (cheap to clone) |
| `KnownSecretRegistry` | Cross-spike secret registry for defence in depth (SS-AUD-003) |
| `InMemoryLogSink` | Bounded ring buffer of redacted log lines |
| `RedactingLogLayer` | `tracing-subscriber` Layer feeding `InMemoryLogSink` |
| `PttSanitizer<L>` | Layer decorator dropping PTT-sensitive records |
| `DiagnosticExport` | User-facing export bundle |
| `ProtocolEventRecorder` | Protocol event ring buffer (SRS-097) |
| `DiagnosticsError` | Export, Io |
| `REDACTION_MARKER` | `"[REDACTED]"` |
### Key methods
**Redactor:**
- `with_default_policy()` / `with_secrets(registry)` — construct
- `redact(s)``String` — apply policy
- `secrets()``&KnownSecretRegistry` — register secrets
**KnownSecretRegistry:**
- `register(secret)` — add a known-secret value (≥4 chars)
- `contains_substr(haystack)``bool` — substring check
**InMemoryLogSink:**
- `new(capacity, redactor)` — construct
- `push(raw)` — redact and store a line
- `snapshot()``Vec<String>` — current buffer contents
**DiagnosticExport:**
- `from_sink(sink, metadata)` — build from log sink
- `with_android_audio(yaml)` / `with_network_info(info)` / `with_protocol_events(events)` — attach optional sections
- `to_text()``String` — render as multi-line plaintext
**ProtocolEventRecorder:**
- `new(capacity)` — construct
- `record_connected(server_name)` / `record_disconnected(reason)` / `record_reconnecting(attempt, delay)`
- `drain()``Vec<String>` / `snapshot()``Vec<String>`
+48
View File
@@ -0,0 +1,48 @@
# chanora_prefetch
Server-address prefetch cache and policy. Owns speculative server-resolution warming so that when the user presses Connect, a fresh DNS/SRV result may already be available, reducing perceived join latency.
## Architecture
### Cache model
`ServerPrefetchCache` holds at most one entry — the latest prefetched resolution. A generation counter prevents stale async completions from overwriting newer results. TTL is 120 seconds.
### Flow
1. Flutter typing triggers `prefetch_server(host)` via the bridge.
2. `ServerPrefetcher::prefetch()` normalizes the host, bumps the generation, and spawns a fire-and-forget tokio task that calls `chanora_resolver::ChanoraResolver::resolve_client_address()`.
3. On success, the result is stored if its generation is still current.
4. When `chanora_core::connect()` is called, it checks `fresh_match(host)`. If a fresh (non-expired) entry matches, it's used as the `resolved_address` in `ConnectConfig`, bypassing a second DNS round trip.
### Generation guard
If the user types another host while the first prefetch is in flight, the generation advances. The slower completion is discarded because its generation no longer matches. The most recent entry always wins.
## Public API Summary
### Types
| Type | Role |
|---|---|
| `ServerPrefetcher` | Public API: schedule prefetches, query fresh matches |
| `ServerPrefetchError` | ResolverInit, Resolution, InvalidSocketAddress |
### Key methods on `ServerPrefetcher`
- `new()` — construct with empty cache
- `prefetch(host)` — schedule a fire-and-forget resolution (async). Only reports synchronous setup failures; DNS failures are logged.
- `fresh_match(host)``Option<SocketAddr>` — return a cached address if it matches and is within TTL (async)
### Test-only methods (behind `cfg(test)` or `feature = "test-support"`)
- `begin_for_test(host)` — bump generation
- `store_success_for_test(generation, host, addr, instant)` — inject a result
- `latest_generation_for_test()` — read current generation
- `fail_next_prefetch_setup_for_test(error)` — inject a setup failure
## Design notes
- Blank/whitespace-only hosts are silently skipped.
- Hosts are normalized to lowercase trimmed strings before matching.
- A fresh entry remains usable while a newer prefetch is in flight; stale completions are rejected by the generation guard.
+3
View File
@@ -194,6 +194,9 @@ impl ServerPrefetcher {
}
}
// TODO(refactor): `normalize_host` duplicates `chanora_resolver::normalize_args`
// host trimming. Both do `host.trim().to_lowercase()`. Could move to a shared
// utility in chanora_protocol or a tiny chanora_common crate if more crates need it.
fn normalize_host(host: &str) -> String {
host.trim().to_lowercase()
}
+3
View File
@@ -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"
+52
View File
@@ -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.
+59 -35
View File
@@ -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
+599
View File
@@ -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 = "";
// 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);
}
}
+13 -2
View File
@@ -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}"))
}
}
+64
View File
@@ -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.
+20 -33
View File
@@ -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,
+68
View File
@@ -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.
+67 -43
View File
@@ -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
+2 -1
View File
@@ -50,6 +50,7 @@ Chanora is a Flutter application with a Rust core. Flutter owns the user-facing
| 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
@@ -63,7 +64,7 @@ Flutter UI/widgets/services
-> chanora_audio -> platform audio APIs / Opus / DSP
-> chanora_storage -> platform secure storage / SQLite
-> chanora_diagnostics
-> chanora_prefetch -> chanora_resolver -> network DNS/TSDNS
-> 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.
+8 -2
View File
@@ -21,14 +21,20 @@ This Software Detailed Design defines the module-level design details needed for
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | Connection orchestration, reconnect, bridge-facing event DTOs, network diagnostics, PTT state, storage coordination | Rust core |
| 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 Build and release hooks | `.github/workflows/`, `tools/`, platform project files | CI, unsigned iOS build, benchmark advisory, platform smoke procedures | Release / platform architecture |
| 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
+70
View File
@@ -0,0 +1,70 @@
# Audio Loopback Test Tool Design
**Date:** 2026-06-11
**Status:** Design proposal
**TODO:** TODO-046
**Requirements:** SysRS-073, SRS-083
**Effort:** L
**Dependencies:** TODO-054 (audio loopback harness)
## Purpose
End-to-end audio quality verification. Sends a known test signal through the full encode→decode→playback→capture loop and measures signal quality metrics to verify the entire audio pipeline works correctly on a given platform.
## Inputs
- Known test signal (sine sweep, white noise, or chirp)
- Loopback device configuration (virtual audio device or hardware loopback)
- Test duration and sample rate
## Outputs
- Signal quality metrics: SNR (dB), latency (ms), jitter (ms), THD+N (%)
- Pass/fail against acceptance thresholds
- Captured loopback audio WAV for manual inspection
## Architecture
```text
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Test │────>│ Opus │────>│ Loopback │────>│ Opus │
│ Signal │ │ Encode │ │ Device │ │ Decode │
│ Generator│ │ │ │ (virtual) │ │ │
└──────────┘ └───────────┘ └───────────┘ └─────┬─────┘
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌────v─────┐
│ Report │<────│ Metrics │<────│ Compare │<────│ Capture │
│ (SNR, │ │ Extract │ │ (original │ │ (loopback│
│ latency)│ │ │ │ vs recv) │ │ audio) │
└──────────┘ └───────────┘ └───────────┘ └──────────┘
```
1. **Generate:** Create known test signal (e.g., 1kHz sine, sweep)
2. **Encode:** Pass through Opus encoder (matching voice pipeline config)
3. **Loopback:** Send encoded audio through virtual audio device
4. **Decode:** Capture loopback audio and decode through Opus decoder
5. **Compare:** Cross-correlate original and received signals
6. **Measure:** Extract SNR, latency (peak correlation offset), jitter, THD+N
## Implementation Plan
- New Rust binary crate: `tools/audio-loopback-test/`
- Reuse `chanora_audio` Opus encoder/decoder wrappers
- Virtual audio device: BlackHole (macOS), VB-Audio (Windows), snd-aloop (Linux)
- Cross-correlation for latency measurement
- CLI interface: `audio-loopback-test [--duration 5] [--signal sine|sweep|noise] [--device <name>]`
- Requires TODO-054 (loopback harness) for CI virtual device setup
## Dependencies
- `chanora_audio` (Opus encode/decode, audio config)
- `opus` crate (encoder/decoder)
- `hound` (WAV I/O)
- Virtual audio device (platform-specific, from TODO-054)
## Verification
- Unit test: encode→decode roundtrip without loopback, verify signal preserved
- Integration test: full loopback on macOS with BlackHole, verify SNR > threshold
- Platform test: run on each target OS with configured virtual device
- Demo: run tool on developer machine, show metrics report
@@ -0,0 +1,65 @@
# Audio Processing Test Tool Design
**Date:** 2026-06-11
**Status:** Design proposal
**TODO:** TODO-044
**Requirements:** SysRS-074, SRS-083
**Effort:** L
## Purpose
Test the audio DSP pipeline (AEC, NS, AGC, HPF) in isolation. Measures processing latency, signal quality, and verifies each filter stage produces expected output for known input signals.
## Inputs
- WAV file (reference signal) or live microphone input
- Processing configuration (enable/disable AEC, NS, AGC, HPF)
- Optional: reference signal for AEC (far-end playback)
## Outputs
- Processed audio WAV file
- Per-stage metrics: latency (ms), signal level (dBFS), spectral changes
- Pass/fail per processing stage against acceptance thresholds
## Architecture
```text
┌──────────┐ ┌───────────────────────────────────────┐ ┌──────────┐
│ WAV / │────>│ DSP Chain: HPF → NS → AEC → AGC │────>│ Processed│
│ Mic Input│ │ (chanora_audio processors) │ │ WAV + │
└──────────┘ └────────────────────────┬──────────────┘ │ Metrics │
│ └──────────┘
┌──────v───────┐
│ Metrics │
│ Collector │
│ (latency, │
│ dBFS, SNR) │
└──────────────┘
```
1. **Load:** Read WAV file or open mic stream
2. **Process:** Feed PCM frames through each enabled DSP stage sequentially
3. **Measure:** Collect per-stage latency and signal metrics
4. **Output:** Write processed WAV and print metrics table
## Implementation Plan
- New Rust binary crate: `tools/audio-processing-test/`
- Reuse `chanora_audio` processors: `HpfProcessor`, noise suppression, AEC, AGC
- WAV I/O via `hound` crate
- CLI interface: `audio-processing-test <input.wav> [--output processed.wav] [--stages hpf,ns,aec,agc]`
- Metrics: frame-level latency, input/output RMS, spectral centroid shift
## Dependencies
- `chanora_audio` (HPF, NS, AEC, AGC processors)
- `hound` (WAV read/write)
- `chanora_audio::engine` (AudioProcessingConfig)
## Verification
- Unit test: known-tone input through HPF, verify low-frequency attenuation
- Unit test: known-noise input through NS, verify noise floor reduction
- Integration test: full pipeline on reference WAV, verify output within thresholds
- Demo: run tool on sample file, show metrics output
+119
View File
@@ -0,0 +1,119 @@
# Badge Fetching Design (TODO-065)
## Purpose
Fetch, cache, and display TeamSpeak client badges. Badges are visual indicators (icons) shown next to client names in the UI.
## Protocol Details
**Source:** `badges-content.teamspeak.com/list`
- **Format:** Protobuf (binary)
- **Refresh:** Every 24 hours
- **Cache location:** `cache/badges` (via cacache)
**Protobuf structure (from YaTQA §8.9):**
1. `BigNum`: revision number
2. `BigNum`: Unix timestamp
3. Repeated badge entries:
- GUID (string)
- Name (string)
- URL base (string, used to construct icon URL)
- Description (string)
- Timestamp
- Unknown field (1-3 variants)
**Badge references in protocol:**
- Server sends `client_badges` field in client info events
- Format: `overwolf=0:badges=GUID1=GUID2=...`
- Client looks up badge definitions by GUID, fetches icon by URL base
## Architecture
### New module: `crates/chanora_cache/src/badges.rs`
```
badges.rs
├── BadgeMeta { guid, name, url_base, description }
├── BadgeCache
│ ├── fetch_badge_list() -> Vec<BadgeMeta>
│ ├── get_icon(guid) -> Option<bytes>
│ ├── refresh_if_stale()
│ └── metadata: RwLock<HashMap<Guid, BadgeMeta>>
```
### Extend `chanora_cache/src/lib.rs`
- Add `PREFIX_BADGE: &str = "bg_"` for badge icon blobs
- Update `validate_key()` to accept badge prefix (GUID format: 8-4-4-4-12 hex)
## Data Flow
```
1. On connect → server sends client_badges (GUIDs)
2. BadgeCache.refresh_if_stale():
- Check last_refresh timestamp
- If >24h: fetch badges-content.teamspeak.com/list
- Parse Protobuf → Vec<BadgeMeta>
- Update metadata map
3. For each GUID in client_badges:
- Lookup BadgeMeta by GUID
- Fetch icon from url_base (HTTP GET)
- Cache icon blob in BlobCache (PREFIX_BADGE + guid)
- Return icon bytes to UI
4. Flutter UI displays badge icons in:
- Client info panel (profile)
- Chat message sender badges
```
## Dependencies
| Crate | Status | Purpose |
|-------|--------|---------|
| `reqwest` | Already in `chanora_protocol` | HTTP fetch |
| `prost` | **Add** | Protobuf parsing |
| `cacache` | Already in `chanora_cache` | Blob storage |
| `tokio` | Already | Async runtime |
**Cargo.toml additions for `chanora_cache`:**
```toml
reqwest = { version = "0.13", default-features = false, features = ["rustls-tls"] }
prost = "0.13"
```
## Integration Points
| Component | Integration |
|-----------|-------------|
| `chanora_protocol/src/adapter.rs` | Parse `client_badges` from `notifyclientupdated` events |
| `chanora_bridge` | Expose `get_badge_icon(guid)` and `get_client_badges(client_id)` to Flutter |
| Flutter UI | Display badge icons in `ClientInfoPanel` and `ChatMessage` widgets |
## Files to Create/Modify
- **Create:** `crates/chanora_cache/src/badges.rs`
- **Modify:** `crates/chanora_cache/src/lib.rs` (add `PREFIX_BADGE`, export module)
- **Modify:** `crates/chanora_cache/Cargo.toml` (add reqwest, prost)
- **Modify:** `crates/chanora_protocol/src/adapter.rs` (parse client_badges)
- **Modify:** `crates/chanora_bridge/src/` (expose badge API to Flutter)
## Error Handling
- **Network failure:** Log warning, serve stale cache if available, retry on next refresh
- **Protobuf parse error:** Log error, keep previous badge data, alert user "badges unavailable"
- **Icon fetch failure:** Cache miss, show placeholder or skip badge display
## Effort Estimate
**L (3-5 days)**
- Day 1: Protobuf schema research, add prost dependency, parse badge list
- Day 2: BadgeCache implementation, icon fetch and caching
- Day 3: Protocol adapter integration (parse client_badges)
- Day 4: Flutter bridge API, UI display
- Day 5: Testing, error handling, edge cases
## Open Questions
1. **Protobuf schema:** Need to define `.proto` file or use dynamic parsing. Prost requires compile-time schema — may need to reverse-engineer from reference or use `protobuf` crate for runtime parsing.
2. **Icon URL construction:** Exact URL pattern for badge icons needs verification (likely `https://badges-content.teamspeak.com/{url_base}.png`).
3. **Offline mode:** Should we ship a static badge list as fallback? (YAGNI for now)
+63
View File
@@ -0,0 +1,63 @@
# Event Replay Tool Design
**Date:** 2026-06-11
**Status:** Design proposal
**TODO:** TODO-043
**Requirements:** SysRS-171, SRS-098
**Effort:** L
## Purpose
Replay recorded protocol events for debugging state synchronization. Enables deterministic reproduction of state bugs by replaying a captured event sequence through the state reducer and comparing the result with expected state.
## Inputs
- Event log file (JSON or protobuf) recorded in diagnostics mode (SRS-097)
- Optional: expected final state snapshot for comparison
## Outputs
- Replayed session state at each step
- Diff between replayed state and expected state (if provided)
- Reducer execution trace for debugging
## Architecture
```text
┌──────────────┐ ┌───────────────┐ ┌──────────────────┐
│ Event Log │────>│ Event Iterator│────>│ State Reducer │
│ (JSON/PB) │ │ (ordered) │ │ (chanora_state) │
└──────────────┘ └───────────────┘ └────────┬─────────┘
┌────────v─────────┐
│ State Comparator │
│ (expected vs │
│ actual) │
└──────────────────┘
```
1. **Load:** Parse event log file into ordered `StateEvent` sequence
2. **Iterate:** Feed events one-by-one to `chanora_state` reducer
3. **Capture:** Record state after each event for step-through debugging
4. **Compare:** If expected snapshot provided, diff final state against it
5. **Report:** Output pass/fail with reducer trace and state diff
## Implementation Plan
- New Rust binary crate: `tools/event-replay/`
- Reuse `chanora_state::ServerState` and reducer functions directly
- JSON event format matches `chanora_diagnostics` event recording output
- CLI interface: `event-replay <event-log> [--expected <snapshot>] [--trace]`
- `--trace` flag prints state after each event
## Dependencies
- `chanora_state` (reducer, ServerState)
- `chanora_diagnostics` (event log format)
- `serde_json` or `prost` (event deserialization)
## Verification
- Unit test: replay known event sequence, assert final state matches expected
- Integration test: record events from live session, replay, verify state reconstruction
- Demo: replay recorded session and show state diff
@@ -0,0 +1,169 @@
# File Transfer Protocol Design
**Date:** 2026-06-11
**Status:** Implemented
**Location:** `core/chanora_core/src/file_transfer.rs`
## Overview
TeamSpeak 3 file transfer protocol for downloading avatars and icons. Uses a two-phase approach: command phase over encrypted UDP, then raw TCP data transfer.
## Protocol Specification
### Two-Phase Transfer
1. **Command Phase** — Client sends `ftinitdownload` over main encrypted UDP connection
2. **Transfer Phase** — Client opens TCP connection to server's file transfer port (default 30033), sends `ftkey`, receives raw bytes
### Relevant Commands
| Command | Direction | Purpose |
|---|---|---|
| `ftinitdownload` | Client → Server | Initialize download, returns `ftkey`, `port`, `size` |
| `ftgetfileinfo` | Client → Server | Get file metadata |
| `ftgetfilelist` | Client → Server | List files in channel repository |
| `ftinitupload` | Client → Server | Initialize upload |
| `ftdeletefile` | Client → Server | Delete a file |
| `ftcreatedir` | Client → Server | Create directory |
| `ftrenamefile` | Client → Server | Rename/move file |
### `ftinitdownload` Command
```
ftinitdownload clientftfid={id} name={path} cid={channelId} cpw={password} seekpos={seek} proto=0
```
**Parameters:**
| Parameter | Type | Description |
|---|---|---|
| `clientftfid` | `u16` | Client-side transfer ID |
| `name` | `string` | File path (e.g., `/avatar_abcdef`) |
| `cid` | `ChannelId` | Channel scope (0 = server-level) |
| `cpw` | `string` | Channel password (empty for server-level) |
| `seekpos` | `u64` | Resume offset (0 for fresh download) |
| `proto` | `u8` | Protocol version (always 0) |
**Server Response:**
| Field | Type | Description |
|---|---|---|
| `clientftfid` | `u16` | Echo of 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 TCP connection |
### TCP Transfer Flow
1. Open TCP connection to `server_ip:port`
2. Send `ftkey` followed by newline
3. Read exactly `size` bytes of raw file data
4. Close TCP connection
### File Paths
Files are addressed by path scoped to channel ID:
- `cid=0` — Server-level repository (avatars, icons live here)
- `cid=N` — Channel-specific repository
**Avatar path:** `/avatar_<hex>` where `<hex>` is derived from client UID:
```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
}
```
**Icon path:** `/icon_<id>` where `<id>` is unsigned CRC32 of icon bytes.
## Implementation Details
### Architecture
```
Bridge (get_avatar/get_icon)
FileTransferService
1. Check BlobCache → hit? return bytes
2. Check negative cache → hit? return None
3. Check in-flight map → already downloading? await existing
4. Acquire concurrency semaphore (max 2)
5. Download via ProtocolClient
6. Store in BlobCache
7. Notify all waiters
```
### Code Location
- `core/chanora_core/src/file_transfer.rs``FileTransferService` (344 lines)
- `crates/chanora_cache/src/lib.rs``BlobCache` (cacache-backed)
- `crates/chanora_protocol/src/adapter.rs``uid_to_avatar_path()` (line 1561)
### Features Implemented
- [x] `ftinitdownload` command over encrypted UDP
- [x] TCP data transfer with `ftkey` authentication
- [x] Avatar download (`/avatar_<hex>`)
- [x] Icon download (`/icon_<crc32>`)
- [x] Content-addressed blob cache (cacache-backed)
- [x] Request coalescing (multiple requests for same hash = 1 download)
- [x] Rate limiting (max 2 concurrent downloads)
- [x] Negative cache (5-minute TTL for 404s)
- [x] Cache integrity verification (SSRI)
- [x] Cross-server dedup (same content = same blob)
### Cache Architecture
**Storage:** `<cache_dir>/chanora/blobs/` managed by `cacache`
**Key mapping:**
| Protocol key | cacache key | Example |
|---|---|---|
| Avatar MD5 | `av_<md5hex>` | `av_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6` |
| Icon CRC32 | `ic_<crc32u>` | `ic_123456789` |
**Why content-addressed:**
- `client_flag_avatar` = MD5 of avatar bytes (computed by uploader)
- `icon_id` = CRC32 of icon bytes (computed by uploader)
- Same content on any server = same hash = stored once
### Error Handling
| tsclientlib error | ProtocolError | Action |
|---|---|---|
| Permission denied | `ServerRejected { code, message }` | Negative cache (5 min) |
| File not found | `ServerRejected { code, message }` | Negative cache (5 min) |
| Network/TCP failure | `Backend(String)` | Retry with backoff |
| Timeout | `Timeout` | Retry with backoff |
| Connection lost | `Lost(String)` | Fail all pending downloads |
## Anti-Flood Strategy
- Max 2 concurrent downloads per server
- 5-second pause on flood error, then resume at reduced rate
- Lazy download (only when UI needs to display)
- No proactive download of all avatars on connect
## Testing
Unit tests in `core/chanora_core/src/file_transfer.rs`:
- `returns_cached_avatar_without_connection` (line 295)
- `negative_cache_short_circuits_not_connected` (line 313)
- `returns_cached_icon_without_connection` (line 333)
## References
- YaTQA Reference §8.3: File transfer protocol details
- `docs/architecture/file-transfer-design.md` — Full design document (737 lines)
- `docs/architecture/file-transfer-research.md` — Research findings (770 lines)
- `docs/architecture/file-transfer-implementation-plan.md` — Implementation plan (1315 lines)
- `core/chanora_core/src/file_transfer.rs` — Implementation (344 lines)
+147
View File
@@ -0,0 +1,147 @@
# iOS Audio Session Lifecycle — Integration Test Plan
## Overview
Chanora uses Apple's **VoiceProcessingIO** (VPIO) AudioUnit on iOS/macOS for
voice capture and playback. The audio session is configured in Swift
(`AppDelegate`) with `AVAudioSession.Category.playAndRecord` and
`AVAudioSession.Mode.default`. This document defines the integration tests
needed to verify correct behavior across session transitions, interruptions,
and route changes.
## Architecture Summary
| Layer | Responsibility |
|-------|---------------|
| `AppDelegate.swift` | Sets `AVAudioSession` category/mode, handles route-change and interruption notifications |
| `IosVoiceUnit` (Rust) | Opens VPIO AudioUnit, pins 48 kHz Int16 mono, installs render + input callbacks |
| `AudioEngine::ios_restart_voice_unit` | Restarts the VPIO unit after a route change |
| `AudioEngine::ios_pause_voice_unit` / `ios_resume_voice_unit` | Suspends audio during interruptions |
| `route_policy.rs` | Maps `AudioRoute` to recommended `AudioProcessingConfig` (AEC/NS/AGC ownership) |
## Test Scenarios
### 1. Session Activation and Deactivation
| ID | Scenario | Steps | Expected Behavior |
|----|----------|-------|-------------------|
| S-01 | Cold start session activation | Launch app → connect to server → join voice channel | VPIO unit starts, mic input flows, audio plays through default route |
| S-02 | Session deactivation on disconnect | While in voice → disconnect from server | VPIO unit stops, `AudioEngine::stop()` called, session category restored |
| S-03 | Session mode verification | After activation, query `AVAudioSession.mode` | Must be `.default` (not `.voiceChat`) to avoid ducking |
### 2. Audio Interruption Handling
| ID | Scenario | Steps | Expected Behavior |
|----|----------|-------|-------------------|
| I-01 | Phone call interruption | While in voice → receive incoming call | `AVAudioSession.interruptionNotification` fires with `.began`; VPIO paused via `ios_pause_voice_unit` |
| I-02 | Phone call ends | After I-01 → call ends | Interruption notification fires with `.ended`; VPIO resumed via `ios_resume_voice_unit` if session was active |
| I-03 | Siri activation | While in voice → invoke Siri | Interruption `.began` → pause; Siri dismisses → `.ended` → resume |
| I-04 | Alarm / timer | While in voice → alarm fires | Audio ducks (not interrupted); voice continues at reduced volume |
| I-05 | Third-party audio app | While in voice → open Spotify and play music | Other audio ducks; Chanora voice remains active |
| I-06 | Interruption during route change | While switching routes → phone call arrives | Both events handled; no crash, VPIO restarts cleanly after both resolve |
### 3. Route Change Handling
| ID | Scenario | Steps | Expected Behavior |
|----|----------|-------|-------------------|
| R-01 | Plug in wired headset | While on speaker → connect Lightning/USB-C headphones | Route changes to `.wiredHeadset`; `ios_restart_voice_unit` called; AEC disabled (no acoustic echo path); `route_policy.rs` returns `WiredHeadset` config |
| R-02 | Unplug wired headset | While on wired headset → disconnect | Route changes to `.speaker`; VPIO restarts; AEC re-enabled via platform VPIO |
| R-03 | Connect Bluetooth HFP | While on speaker → connect BT headset in HFP mode | Route changes to `.bluetoothHfp`; VPIO restarts; AEC off (headset firmware handles it) |
| R-04 | Disconnect Bluetooth HFP | While on BT HFP → turn off headset | Route falls back to speaker; VPIO restarts with platform AEC |
| R-05 | Switch to Bluetooth A2DP | While on speaker → connect A2DP-only device | Route changes to `.bluetoothA2dp`; transmit blocked (A2DP is output-only); playback continues |
| R-06 | Toggle speaker/earpiece | Use in-app audio output picker | `overrideOutputAudioPort` called; VPIO restarts; audio actually moves (not just metadata) |
| R-07 | AirPods connect/disconnect | While on speaker → AirPods connect → AirPods case closed | Route transitions handled; VPIO restarts on each change |
| R-08 | Rapid route changes | Connect/disconnect headset 5 times in 10 seconds | No crash, no audio leak, VPIO restarts cleanly each time |
| R-09 | Route change during mute | While muted → route changes | VPIO restarts; mute state preserved; no audio leak |
### 4. Audio Ducking Configuration
| ID | Scenario | Steps | Expected Behavior |
|----|----------|-------|-------------------|
| D-01 | Ducking disabled on startup | App launches and joins voice | `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` set with `mEnableAdvancedDucking=0`, `mDuckingLevel=Min` |
| D-02 | Music playback while in voice | Play music via Music app → join voice channel | Music volume is NOT heavily attenuated; voice and music coexist |
| D-03 | Game audio while in voice | Play a game with audio → join voice | Game audio is NOT heavily attenuated |
### 5. VPIO Stream Format Verification
| ID | Scenario | Steps | Expected Behavior |
|----|----------|-------|-------------------|
| F-01 | Output bus format | After VPIO start, inspect bus 0 stream format | 48 kHz, Int16, mono, signed integer, packed |
| F-02 | Input bus format | After VPIO start, inspect bus 1 stream format | 48 kHz, Int16, mono, signed integer, packed |
| F-03 | Callback frame count | Log `num_frames` in render callback | iOS: 480 frames (10 ms); macOS: 512 frames (10.67 ms) |
| F-04 | Audio quality roundtrip | Speak into mic → loopback to speaker | No distortion, no resampling artifacts, correct latency |
### 6. Route Policy Correctness
| ID | Scenario | Steps | Expected Behavior |
|----|----------|-------|-------------------|
| P-01 | Speaker route policy | Route = Speaker | `ios_route_policy` returns: AEC=Platform, NS=Platform, AGC=Platform, backend=PlatformVoiceProcessing |
| P-02 | Wired headset policy | Route = WiredHeadset | AEC=Off, NS=Conservative, AGC=Conservative, backend=Noop |
| P-03 | BT HFP policy | Route = BluetoothHfp | AEC=Off, NS=Conservative, AGC=Conservative, backend=PlatformVoiceProcessing |
| P-04 | A2DP policy | Route = BluetoothA2dp | All processing off, VAD disabled, transmit blocked |
| P-05 | INV-009 invariant | Any route | Sonora AEC never enabled simultaneously with platform VPIO |
| P-06 | User VAD preserved on route change | Set VAD=WebRTC → change route | New config keeps VAD=WebRTC and hangover timing |
## Device Requirements
### Required Devices
| Device | OS | Reason |
|--------|-----|--------|
| iPhone (Lightning or USB-C) | iOS 16+ | Primary target; VPIO, route changes, interruptions |
| iPhone with Face ID | iOS 17+ | `OtherAudioDuckingConfiguration` property availability |
| AirPods (any generation) | — | Bluetooth A2DP/HFP route testing |
| Bluetooth HFP headset | — | Non-Apple BT headset route testing |
| Lightning/USB-C wired headset | — | Wired route testing |
| iPad (optional) | iPadOS 16+ | Verify identical VPIO behavior |
### Simulator Limitations
- VPIO render callback cadence differs from real hardware
- Route changes are not testable on simulator
- Interruption notifications are unreliable on simulator
- **Recommendation**: All integration tests must run on physical devices
## Automation Approach
### Phase 1: Manual Test Matrix
Execute scenarios S-01 through P-06 on physical devices using this checklist.
Record pass/fail and any audio artifacts observed.
### Phase 2: XCUITest + Rust Harness
```
┌─────────────┐ ┌──────────────┐ ┌───────────────┐
│ XCUITest │────▶│ FRB bridge │────▶│ AudioEngine │
│ (Swift) │ │ test helper │ │ (Rust) │
└─────────────┘ └──────────────┘ └───────────────┘
```
1. **FRB test helper**: Add a `#[flutter_rust_bridge::frb]` test function
that starts `AudioEngine`, runs for N seconds, and returns stats
(frames_sent, frames_received, xruns, output_underruns).
2. **XCUITest**: Launches the app, connects to a test server, triggers
the FRB helper, then uses `XCUIDevice` APIs to simulate:
- Route changes via `XCUIDevice.shared().press(.volumeUp)` + BT pairing
- Interruptions via `XCUISiriService` (Siri) or call simulation
3. **Assertions**: Verify stats counters are within expected ranges
(no xruns, no output underruns, frames_sent > 0).
### Phase 3: Continuous Monitoring
Add a telemetry event for each VPIO restart, pause, resume, and
interruption. Track:
- Restart count per session (should be ≤ number of route changes)
- Pause-to-resume latency (should be < 500 ms)
- Xrun count per session (should be 0 under normal conditions)
## References
- `crates/chanora_audio/src/ios_voice_unit.rs` — VPIO AudioUnit setup
- `crates/chanora_audio/src/engine/lifecycle.rs` — Engine start/stop/restart
- `crates/chanora_audio/src/route_policy.rs` — Route-to-config policy
- Apple: [Audio Session Programming Guide](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/)
- Apple: [Audio Unit Hosting Guide for iOS](https://developer.apple.com/library/archive/documentation/MusicAudio/Conceptual/AudioUnitHostingGuide_iOS/)
@@ -0,0 +1,62 @@
# Platform Backend Extraction Evaluation — chanora_audio
**Date:** 2026-06-11
**Status:** Evaluation (not implementation)
**Crate:** `chanora_audio` (~18,500 lines total)
## Current Structure
Platform-specific code is isolated into dedicated files with `#[cfg]` gates at the module boundary. The engine module (`engine/mod.rs`, `engine/lifecycle.rs`) uses pervasive inline `cfg` attributes to dispatch across platforms.
### Lines Per Platform (exclusive files only)
| Platform | Files | Lines | Key Dependencies |
|----------|-------|-------|-----------------|
| **iOS/macOS** | `ios_voice_unit.rs`, `vad/apple_coreml.rs`, `ptt_backends/macos.rs`, `voice_render.rs` | ~2,600 | `coreaudio-rs`, `dispatch2` |
| **Android** | `android_voice_unit.rs`, `android_render_ring.rs`, `audio_event_queue.rs` | ~2,130 | `oboe`, `jni`, `ndk-context`, `bytemuck` |
| **Desktop** | `engine/capture.rs`, `engine/render.rs`, `sdl_output.rs`, `ptt_backends/windows*.rs`, `ptt_backends/linux.rs`, `vad/silero_onnx.rs` | ~4,400 | `cpal`, `sdl2`, `ort`, `windows`, `zbus` |
### Shared Code (cross-platform)
| Module | Lines | Notes |
|--------|-------|-------|
| `mobile_voice_backend.rs` | 813 | Trait + types for iOS/Android backends |
| `engine/mod.rs` + `engine/lifecycle.rs` | 2,255 | Heavy inline `cfg` dispatch |
| `processor/` | ~500 | AudioProcessor trait + backends |
| `vad/mod.rs` + `vad/resampler.rs` | 393 | VAD trait + WebRTC fallback |
| Other shared (`frame`, `opus_voice`, `ptt`, `voice_activity`, etc.) | ~3,200 | Platform-neutral |
## Assessment
### Would splitting help?
**No — not recommended at this time.**
### Reasons Against Splitting
1. **cfg gating already works.** Platform files are cleanly isolated at the module boundary. The compiler strips unused code per-target; a separate crate doesn't add compilation speed for the active target.
2. **Shared types are deeply embedded.** `AudioError`, `AudioEffects`, `AudioProcessingConfig`, `VoiceActivityStateMachine`, `OpusEncoder` setup, `frame::*` helpers, and the `AudioProcessor` trait are used by every platform. Extracting these into a `chanora_audio_common` crate is mandatory before splitting, adding a dependency node every platform crate must pull in.
3. **engine/lifecycle.rs is the real problem — but it's an integration point, not a platform backend.** This 1,149-line file dispatches `start_audio` / `stop_audio` across all platforms with inline `cfg`. Splitting backends into separate crates wouldn't reduce this file's complexity — it would just move the cross-crate import surface here.
4. **Dependency graph complexity.** The current single crate has 5 `cfg`-gated dependency blocks in Cargo.toml. Splitting into 3+ crates means each platform crate needs its own Cargo.toml with the shared types dependency, and the top-level `chanora_audio` (or `chanora_core`) must depend on all of them with target-conditional `cfg` features.
5. **Test surface stays the same.** Platform-specific tests already compile only on their target OS. A crate boundary doesn't improve test isolation.
6. **The `MobileVoiceAudioBackend` trait is the natural seam — and it already exists.** `mobile_voice_backend.rs` defines the cross-platform interface. The iOS backend will implement it under SDD-117. This is the correct abstraction boundary without adding a crate boundary.
### When Splitting Would Make Sense
- If build times for **cross-compilation** become painful (building all 3 platform variants from CI)
- If platform-specific dependencies cause **feature flag conflicts** (not observed today)
- If the crate exceeds ~30k lines and the `cfg` density makes navigation difficult
- If a platform team needs to own a crate independently
## Recommendation
Keep the current single-crate structure. Focus cleanup effort on:
1. **Reducing inline `cfg` in `engine/lifecycle.rs`** — extract platform dispatch into the existing `IosVoiceBackend` enum pattern
2. **Back-filling `IosVoiceUnit` to `MobileVoiceAudioBackend`** (SDD-117) — this unifies the mobile interface
3. **Documenting the cfg convention** — module-level `cfg` at the file boundary (current pattern) vs. inline `cfg` blocks in shared files
+78
View File
@@ -0,0 +1,78 @@
# Protocol Probe Tool Design
**Date:** 2026-06-11
**Status:** Design proposal
**TODO:** TODO-047
**Requirements:** SysRS-128, SRS-123
**Effort:** L
## Purpose
Connect to a TeamSpeak 3-compatible server and enumerate its capabilities. Validates server compatibility, reports supported features, permissions, and protocol behavior for verification and compatibility tracking.
## Inputs
- Server address (host:port)
- Optional: nickname, identity, server password
## Outputs
- Server info: version, platform, name, welcome message
- Supported features: text messaging, voice, file transfer, channel permissions
- Client permissions: talk power, poke power, channel join capabilities
- Protocol compatibility report: pass/fail/warning per feature
- Error codes encountered during probing
## Architecture
```text
┌──────────┐ ┌───────────────┐ ┌──────────────────┐
│ CLI │────>│ Probe Session │────>│ tsclientlib │
│ (host, │ │ (chanora_ │ │ Protocol Adapter │
│ port) │ │ protocol) │ │ │
└──────────┘ └───────┬───────┘ └──────────────────┘
┌──────────v──────────┐
│ Probe Commands: │
│ 1. connect │
│ 2. server info │
│ 3. channel list │
│ 4. client list │
│ 5. send test msg │
│ 6. voice capability│
│ 7. permissions │
└──────────┬──────────┘
┌──────────v──────────┐
│ Compatibility │
│ Report (JSON/text) │
└────────────────────┘
```
1. **Connect:** Establish session via `chanora_protocol` adapter
2. **Query:** Execute probe commands sequentially with timeout
3. **Collect:** Gather responses, errors, and timing for each probe
4. **Report:** Output structured compatibility report
## Implementation Plan
- New Rust binary crate: `tools/protocol-probe/`
- Reuse `chanora_protocol::adapter` for tsclientlib connection
- Probe sequence: connect → server info → channels → clients → text test → voice check → permissions
- Each probe step has independent timeout (5s default)
- CLI interface: `protocol-probe <host[:port]> [--nick probe-bot] [--password <pw>] [--output json|text]`
- JSON output for CI integration; text output for human readability
## Dependencies
- `chanora_protocol` (adapter, tsclientlib wrapper)
- `chanora_core` (connection manager, event types)
- `serde_json` (report output)
- `clap` (CLI argument parsing)
## Verification
- Unit test: mock protocol responses, verify report generation
- Integration test: probe local test server, verify all features detected
- Demo: probe public TeamSpeak server, show compatibility report
- CI: run against test server in CI environment, assert pass on required features
+87
View File
@@ -0,0 +1,87 @@
# TSDNS Protocol Design
**Date:** 2026-06-11
**Status:** Implemented
**Location:** `crates/chanora_resolver/src/lib.rs`
## Overview
TSDNS (TeamSpeak DNS) is a lightweight DNS-like protocol for resolving TeamSpeak server addresses. It operates over TCP port 41144 and provides a simple query-response mechanism.
## Protocol Specification
### Query Format
1. Convert input to lowercase
2. Encode as UTF-8/CESU-8
3. Append magic bytes: `0x0A 0x0D 0x0D 0x0D 0x0A`
4. Send via TCP to port 41144
### Response Format
- **Success:** IP address or hostname (optionally with port)
- **Not found:** Literal string `404`
- **Port placeholder:** `$PORT` means "use the port from the user's input"
### Example
```
Query: "voice.example.com\n\r\r\r\n"
Response: "185.250.249.77:9987"
```
## Implementation Details
### Constants
```rust
const TSDNS_PORT: u16 = 41144;
const TSDNS_TERMINATOR: &[u8] = b"\n\r\r\r\n";
const TSDNS_TIMEOUT: Duration = Duration::from_secs(3);
```
### Resolution Flow
1. **TSDNS SRV lookup** (`_tsdns._tcp.DOMAIN`) - checks for SRV records first
2. **TSDNS TCP fallback** - direct connection to port 41144
3. **Candidate hosts** - tries parent domain then full domain (e.g., `teamspeak.com` then `voice.teamspeak.com`)
### Code Location
- `query_tsdns_socket()` (lines 529-563) - core protocol implementation
- `resolve_tsdns_tcp_candidates()` (lines 454-476) - TCP fallback resolution
- `resolve_tsdns_srv_candidates()` (lines 414-433) - SRV-based resolution
- `tsdns_candidate_hosts()` (lines 977-997) - generates candidate hostnames
### Features Implemented
- [x] TCP port 41144 communication
- [x] Lowercase domain normalization
- [x] Magic bytes terminator (`0x0A 0x0D 0x0D 0x0D 0x0A`)
- [x] TSDNS SRV record support (`_tsdns._tcp.DOMAIN`)
- [x] TCP fallback when no SRV records
- [x] `$PORT` placeholder support
- [x] 3-second timeout per connection
- [x] `404` not-found handling
- [x] IPv4 and IPv6 address support
- [x] Port parsing from response
## DNS Resolution Order (per spec)
1. **SRV TS3:** `_ts3._udp.INPUT` — port overrides user input
2. **SRV TSDNS:** `_tsdns._tcp.DOMAIN`
3. **TSDNS:** Port 41144
4. **DNS:** AAAA, A records (CNAME implicit)
First complete resolution wins. No fallback on connection failure.
## Testing
Unit tests in `crates/chanora_resolver/src/lib.rs`:
- `tsdns_candidates_include_parent_then_full_host` (line 1384)
- `tsdns_endpoint_preserves_srv_method_metadata` (line 1252)
## References
- YaTQA Reference §8.4: TSDNS protocol details
- YaTQA Reference §8.5: DNS resolution order
@@ -0,0 +1,542 @@
# Chanora Codebase Analysis Report
**Date:** 2026-06-11
**Branch:** `docs/codebase-analysis-v2`
**Scope:** Full codebase analysis — functions, tests, documentation, dead code, duplication, architecture, external research
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Function Inventory](#2-function-inventory)
3. [Test Coverage](#3-test-coverage)
4. [Documentation Coverage](#4-documentation-coverage)
5. [Dead Code Analysis](#5-dead-code-analysis)
6. [Useless/Redundant Code](#6-uselessredundant-code)
7. [Duplicated Code](#7-duplicated-code)
8. [Document Link Coverage](#8-document-link-coverage)
9. [Git History & Issue Patterns](#9-git-history--issue-patterns)
10. [PR Analysis](#10-pr-analysis)
11. [API Gap Analysis](#11-api-gap-analysis)
12. [Crate Architecture Analysis](#12-crate-architecture-analysis)
13. [Document Staleness Analysis](#13-document-staleness-analysis)
14. [Test Environment Requirements](#14-test-environment-requirements)
15. [External Research](#15-external-research)
16. [Recommendations](#16-recommendations)
---
## 1. Executive Summary
### Key Metrics
| Metric | Value |
|--------|-------|
| **Total Rust public functions** | ~428 |
| **Total Flutter/Dart functions** | ~586 |
| **Rust test count** | 359 |
| **Flutter test count** | 221 |
| **Total test count** | 580 |
| **Overall test coverage** | 75% of modules |
| **Documentation coverage (Rust)** | 30% of functions have doc comments |
| **Documentation coverage (Flutter)** | 58% of classes documented |
| **Dead code items** | ~20 (5 definitely dead, 11 likely dead, 4 conditionally dead) |
| **Duplicated code patterns** | 10 major patterns |
| **Broken doc links** | 6 (all from missing LICENSE files) |
| **Orphaned docs** | 14 files |
| **Stale documents** | 5/8 major docs need updates |
| **PRs analyzed** | 42 (34 merged, 8 closed) |
| **Issues documented in history** | 45 significant issues |
### Critical Findings
1. **iOS audio is the #1 problem area** — 15+ issues, AVAudioSession lifecycle is the most recurring root cause
2. **chanora_audio has 168 public functions but only 24% documented** — largest crate, most complex
3. **chanora_bridge has 0 tests** — the Flutter-Rust boundary is completely untested
4. **53 SysRS requirements (SysRS-258310) lack SysDes allocation** — breaks traceability
5. **Several features exist without requirements** — poke, file transfer, hard-mute, cache
6. **engine.rs is 3,244 lines** — monolithic, needs splitting
7. **main.dart is 2,910 lines** — monolithic, needs splitting
8. **~39 production `unwrap()` calls in engine.rs** — crash risk on poisoned mutex
---
## 2. Function Inventory
### Summary by Component
| Component | Type | Total `pub fn` | Documented | Coverage |
|---|---|---|---|---|
| **chanora_core** | Rust lib | 68 | 65 | 96% |
| **chanora_audio** | Rust crate | 168 | ~40 | 24% |
| **chanora_bridge** | Rust crate | 65 | ~10 | 15% |
| **chanora_protocol** | Rust crate | 35 | 33 | 94% |
| **chanora_state** | Rust crate | 16 | ~8 | 50% |
| **chanora_storage** | Rust crate | 20 | ~12 | 60% |
| **chanora_cache** | Rust crate | 7 | 7 | 100% |
| **chanora_resolver** | Rust crate | 15 | ~8 | 50% |
| **chanora_diagnostics** | Rust crate | 27 | ~15 | 50% |
| **chanora_prefetch** | Rust crate | 7 | 4 | 57% |
| **Flutter app** | Dart | ~586 | ~5 | <1% |
### Potentially Unused Functions
Functions defined but never called from outside their module:
| Crate | Function | File:Line |
|---|---|---|
| chanora_core | `ChanoraSession::protocol_events_snapshot()` | lib.rs:788 |
| chanora_audio | `AudioEngine::capture_active()` | engine.rs:1621 |
| chanora_audio | `AudioEngine::frames_sent()` | engine.rs:1626 |
| chanora_audio | `AudioEngine::frames_received()` | engine.rs:1631 |
| chanora_audio | `HpfProcessor::process_sample()` | dsp/hpf.rs:63 |
| chanora_audio | `CoreMlWorker::reset_state()` | apple_coreml.rs:140 |
| chanora_audio | `PttCapabilityLevel::is_global()` | ptt.rs:72 |
| chanora_bridge | `handle_media_services_reset_with_route()` | api.rs:696 |
| chanora_bridge | `handle_interruption_began()` | api.rs:703 |
| chanora_state | `ServerState::replace_from_snapshot()` | lib.rs:114 |
| chanora_state | `ServerState::channel_count()` | lib.rs:148 |
| chanora_state | `ServerState::client_count()` | lib.rs:153 |
| chanora_storage | `BookmarkStore::upsert_or_add()` | lib.rs:891 |
| chanora_resolver | `normalize_args()` | lib.rs:778 |
| chanora_resolver | `validate_args()` | lib.rs:785 |
---
## 3. Test Coverage
### Summary by Component
| Component | Source Modules | Modules w/ Tests | Coverage % | Test Count |
|---|---|---|---|---|
| **chanora_audio** | 43 | 35 | 81% | 221 |
| **chanora_bridge** | 5 | 0 | **0%** | 0 |
| **chanora_cache** | 1 | 1 | 100% | 7 |
| **chanora_diagnostics** | 1 | 1 | 100% | 19 |
| **chanora_prefetch** | 1 | 1 | 100% | 6 |
| **chanora_protocol** | 4 | 2 | 50% | 23 |
| **chanora_resolver** | 1 | 1 | 100% | 14 |
| **chanora_state** | 2 | 2 | 100% | 27 |
| **chanora_storage** | 1 | 1 | 100% | 15 |
| **chanora_core** | 5 | 4 | 80% | 27 |
| **Flutter (services)** | 24 | 22 | 92% | 124 |
| **Flutter (widgets)** | 25 | 14 | 56% | 97 |
| **TOTAL** | **113** | **85** | **75%** | **580** |
### Critical Untested Areas
| Component | Module | Risk |
|---|---|---|
| **chanora_bridge** | All 5 modules | **High** — FFI boundary, zero tests |
| **Flutter widgets** | `voice_bar`, `voice_settings`, `connect_widgets` | **High** — core UI |
| **chanora_protocol** | `dto` module | **Medium** — serialization bugs |
| **chanora_core** | `events` module | **Medium** — event system |
---
## 4. Documentation Coverage
### Doc Comment Coverage (Rust)
| Crate | Functions | With Doc Comments | Coverage |
|---|---|---|---|
| chanora_core | 68 | 65 | 96% |
| chanora_audio | 168 | ~40 | 24% |
| chanora_bridge | 65 | ~10 | 15% |
| chanora_protocol | 35 | 33 | 94% |
| chanora_cache | 7 | 7 | 100% |
| chanora_state | 16 | ~8 | 50% |
| chanora_storage | 20 | ~12 | 60% |
| chanora_resolver | 15 | ~8 | 50% |
| chanora_diagnostics | 27 | ~15 | 50% |
| chanora_prefetch | 7 | 4 | 57% |
### Missing README Files
| Directory | Status |
|---|---|
| crates/chanora_audio/ | **MISSING** |
| crates/chanora_bridge/ | **MISSING** |
| crates/chanora_cache/ | **MISSING** |
| crates/chanora_diagnostics/ | **MISSING** |
| crates/chanora_prefetch/ | **MISSING** |
| crates/chanora_protocol/ | **MISSING** |
| crates/chanora_state/ | **MISSING** |
| crates/chanora_storage/ | **MISSING** |
| core/chanora_core/ | **MISSING** |
Only `crates/chanora_resolver/README.md` exists.
---
## 5. Dead Code Analysis
### Definitely Dead (safe to remove)
| Item | File:Line | Evidence |
|------|-----------|----------|
| `AudioFrame10ms` struct | frame.rs:21 | Never referenced outside tests |
| `AudioFrame20ms` struct | frame.rs:28 | Never referenced outside tests |
| `AudioFrame10ms::dbfs()` | frame.rs:58 | Never called anywhere |
| `disable_failed_vad_backend()` | audio_processing.rs:216 | Only called in tests |
| `import 'package:share_plus/share_plus.dart'` | main.dart:55 | `Share` class never used |
### Likely Dead (no production callers)
| Item | File:Line |
|------|-----------|
| `AudioEngine::output_muted()` | engine.rs:1707 |
| `AudioEngine::output_gain()` | engine.rs:1720 |
| `AudioEngine::capture_active()` | engine.rs:1621 |
| `AudioEngine::transmit_gate()` | engine.rs:1614 |
| `AudioEngine::set_transmit_active()` | engine.rs:1601 |
| `AudioEngine::android_diagnostics()` | engine.rs:1694 |
| `TransmitModeSelector::gate()` | transmit_selector.rs:253 |
| `TransmitModeSelector::in_channel()` | transmit_selector.rs:198 |
| `TransmitModeSelector::ptt_held()` | transmit_selector.rs:226 |
| `ReleaseTailTimer::cancel()` | release_tail.rs:133 |
| `ReleaseTailTimer::arm()` | release_tail.rs:70 |
### `#[allow(dead_code)]` Annotated Items
| Item | Status |
|------|--------|
| `AudioCommand::RemoveClient` | TODO: "Wire to client disconnect path" |
| `android_render_ring` module | Conditionally dead (non-Android) |
| `audio_event_queue` module | Conditionally dead (non-Android) |
| `capture_accumulator` module | Conditionally dead (non-Android) |
---
## 6. Useless/Redundant Code
### TODO/FIXME/HACK Inventory (12 entries, 14 occurrences)
| File | Line | Content |
|------|------|---------|
| engine.rs | 2348 | TODO: realtime-audio callback concern |
| mobile_voice_backend.rs | 16 | TODO(SDD-117): back-fill IosVoiceUnit |
| audio_event_queue.rs | 27 | TODO: Wire to client disconnect path |
| audio_lifecycle_service.dart | 151,156 | TODO: macosDefaultDeviceChanged (×2) |
| poke_notification_service.dart | 33-168 | TODO(event-sounds) (×10) |
### Risky Code
| Risk | Location | Count |
|------|----------|-------|
| Production `unwrap()` | engine.rs | ~39 |
| `unsafe impl Send/Sync` | Various | 8 |
| `unimplemented!("")` in FRB | frb_generated.rs | 23 |
### Complexity Warnings
| File | Lines | Risk |
|------|-------|------|
| engine.rs | 3,244 | **High** — monolithic audio engine |
| main.dart | 2,910 | **High** — monolithic Flutter entry |
| adapter.rs | 2,504 | Medium |
| api.rs | 2,432 | Medium |
---
## 7. Duplicated Code
### Major Patterns
| # | Pattern | Files | Lines Saved | Priority |
|---|---------|-------|-------------|----------|
| 1 | AudioProcessingConfig 4× construction | 1 Dart | ~50 | High |
| 2 | Audio processing toggle UI duplication | 2 Dart | ~150 | High |
| 3 | `.map_err(format!)` boilerplate | 5 Rust | ~85 closures | Medium |
| 4 | PttCapability event construction | 1 Rust | ~15 | Medium |
| 5 | DnsFailed/ServerRejected error mirroring | 2 Rust | ~20 | Medium |
| 6 | Platform detection scatter | 6 Dart | ~35 checks | Medium |
| 7 | Host normalization `trim().to_lowercase()` | 3 Rust | ~5 sites | Low |
| 8 | `create_dir_all` pattern | 2 Rust | ~3 sites | Low |
| 9 | `Io(String)` error variant | 3 Rust | ~10 | Low |
| 10 | StateEvent ↔ Delta mirror | 1 Rust | ~5 | Low |
---
## 8. Document Link Coverage
| Metric | Value |
|--------|-------|
| Total links checked | 38 |
| Valid links | 32 |
| Broken links | 6 |
| Health rate | 84.2% |
| Orphaned docs | 14 / 65 (21.5%) |
### Broken Links
All 6 broken links stem from missing `LICENSE-APACHE` and `LICENSE-MIT` files at project root.
### Orphaned Documentation (14 files)
Mostly in `docs/superpowers/plans/` and `docs/superpowers/specs/` — not linked from any index document.
---
## 9. Git History & Issue Patterns
### Issue Categories (45 significant issues)
| Category | Count | Severity |
|----------|-------|----------|
| iOS audio (AVAudioSession/VPIO) | 15+ | Most problematic |
| Realtime thread safety (Mutex) | 5+ | Cross-platform |
| Build toolchain (Xcode Archive) | 4+ | CI-blocking |
| Platform-specific build | 5+ | Cross-compilation |
| Flutter framework bugs | 3+ | Workarounds needed |
| Protocol failures | 3+ | Silent failures |
### Key Patterns
1. **iOS AVAudioSession has 7+ interacting configuration dimensions** — each fix reveals the next layer
2. **Realtime audio callbacks must NEVER use `Mutex::lock()`** — always `try_lock()` with silence fallback
3. **Always verify with `xcodebuild archive`** — not just `flutter build`
4. **Every protocol command should surface errors to UI** — fire-and-forget hides failures
---
## 10. PR Analysis
### PR Summary (42 total)
| Type | Count | Merged |
|------|-------|--------|
| Features | 15 | 7 |
| Bug fixes | 15 | 12 |
| Refactoring | 4 | 3 |
| Documentation | 1 | 1 |
| Build/Chore | 4 | 4 |
| Performance | 1 | 1 |
### Most Active Areas
| Area | PR Count |
|------|----------|
| iOS Audio | 10+ |
| macOS Audio | 5 |
| Voice/VAD | 6 |
| Flutter UI | 8 |
| Protocol/State | 5 |
### Development Patterns
- **Self-review via "Oracle" agent** — effective quality gate
- **Local CI mirroring** — GitHub Actions billing broken
- **PR stacking complexity** — need better branch management
- **Follow-up fix pattern** — PRs stay focused
---
## 11. API Gap Analysis
### Requirements with NO or Minimal Implementation
| Requirement | ID | Status |
|---|---|---|
| UI settings persistence | SysRS-143, SRS-087 | **Missing** |
| Per-user mute persistence | SysRS-145, SRS-088 | **Missing** |
| Event replay tool | SysRS-171, SRS-098 | **Missing** |
| Audio loopback test tool | SysRS-073, SRS-083 | **Missing** | SRS-083 covers both loopback and processing test tools |
| Audio processing test tool | SysRS-074, SRS-083 | **Missing** | SRS-083 shared with SysRS-073 |
| Protocol probe tool | SysRS-128, SRS-123 | **Missing** |
| Notification permission | SysRS-166, SRS-109 | **Missing** |
| Recent server management | SysRS-141, SRS-085 | **Partial** |
| Per-user volume persistence | SysRS-144, SRS-088 | **Partial** |
| Input validation | SysRS-157, SRS-094 | **Partial** |
### Missing Bridge Functions
| Capability | Gap |
|---|---|
| `is_hard_muted()` | Missing read for hard-mute state |
| `get_output_gain()` | Missing gain read-back |
| `get_client_volume()` | Missing per-client volume read |
| `connection_state()` | UI must derive from events only |
| `reconnect()` | No manual trigger |
### Hardcoded Implementations
| Location | Issue |
|---|---|
| `adapter.rs:141` | `pick_client_version()` returns Windows version for ALL platforms |
| `api.rs:350-356` | `log_file_path()` Android returns `None` |
| `chanora_storage` | `keyring_load()` on Android returns `Ok(None)` always |
---
## 12. Crate Architecture Analysis
### Current Dependency Graph
```
chanora_bridge
└─ chanora_core
├─ chanora_audio ────── chanora_protocol ───── chanora_resolver
├─ chanora_state ────── chanora_protocol
├─ chanora_storage (standalone)
├─ chanora_cache (standalone)
├─ chanora_diagnostics (standalone)
└─ chanora_prefetch ── chanora_resolver
```
**No circular dependencies.** Max depth: 3 levels.
### Crate Size
| Crate | Source Lines | Files |
|-------|-------------|-------|
| **chanora_audio** | **18,895** | 26 |
| chanora_bridge | 7,579 | 5 |
| chanora_protocol | 3,372 | 4 |
| chanora_core | 2,716 | 5 |
| chanora_state | 1,870 | 2 |
| chanora_resolver | 1,434 | 1 |
| chanora_storage | 1,332 | 1 |
| chanora_diagnostics | 1,118 | 1 |
| chanora_cache | 359 | 1 |
| chanora_prefetch | 312 | 1 |
### chanora_audio Split Recommendation
**Option A — Extract platform backends (recommended):**
| New Crate | Contents | Lines |
|---|---|---|
| `chanora_audio` (core) | engine, frame types, transmit, VAD, DSP, PTT trait | ~8,000 |
| `chanora_audio_android` | android_voice_unit, render_ring, event_queue, Oboe | ~3,200 |
| `chanora_audio_apple` | ios_voice_unit, coreaudio-rs | ~1,400 |
| `chanora_audio_desktop` | cpal, sdl_output, Windows/Linux PTT | ~3,500 |
**Rationale:** Platform backends are 100% cfg-gated. Splitting removes heavy platform dependencies from core.
### Common Code Extraction
**Not justified at this time.** Patterns are too small for a `chanora_common` crate.
---
## 13. Document Staleness Analysis
### Freshness Scores
| Document | Score | Status |
|---|---|---|
| docs/sysrs.md | 8/10 | Mostly current |
| docs/srs.md | 7/10 | Missing newer requirements |
| docs/sysdes.md | 6/10 | Missing 53 SysRS allocations |
| docs/architecture/sdd.md | 5/10 | Missing many modules |
| docs/architecture/sad.md | 5/10 | Missing components |
| docs/verification/verification-master-plan.md | 6/10 | Stale evidence source |
| docs/governance/traceability-matrix.md | 5/10 | SRS ID mismatches |
| docs/implementation-status-2026-05-28.md | 3/10 | 14 days stale |
### Critical Staleness Issues
1. **53 SysRS requirements (SysRS-258310) lack SysDes allocation** — breaks traceability
2. **Multiple features without requirements** — poke, file transfer, hard-mute, cache
3. **SRS numbering mismatches** in traceability matrix
4. **Implementation status 14 days stale** — missing v0.3.0 features
---
## 14. Test Environment Requirements
### Current CI Coverage
| Platform | CI Status |
|----------|-----------|
| Ubuntu (Rust + Flutter) | ✅ Automated |
| macOS (iOS unsigned build) | ✅ Automated |
| Android | ❌ Not in CI |
| Windows | ❌ Not in CI |
| macOS (native) | ❌ Not in CI |
| Linux (non-Ubuntu) | ❌ Not in CI |
### Physical Devices Needed
| Device | Purpose | Est. Cost |
|---|---|---|
| iPhone 14+ | iOS audio, CoreML VAD | $600-800 |
| Android phone (arm64) | Android audio, Oboe | $200-400 |
| Windows PC | Windows PTT, audio | $500-800 |
| Linux PC | GNOME/Wayland, PipeWire | $500-800 |
| Audio peripherals | USB/BT headset testing | $100 |
| **Total** | | **$2,400-4,200** |
### Implementation Roadmap
| Phase | Timeline | Focus |
|-------|----------|-------|
| Foundation | Weeks 1-4 | Android/Windows/macOS CI builds |
| Device Integration | Weeks 5-8 | Physical devices, audio loopback |
| Full Automation | Weeks 9-12 | E2E tests, audio quality, Firebase |
---
## 15. External Research
### TeaSpeak
- **Status:** Effectively unmaintained since ~2022
- **Architecture:** Closed-source server, TypeScript web client, C++ music bot
- **Lesson for Chanora:** Open-source client + formal engineering process is the right approach
- **Market validation:** TeaSpeak's abandonment creates opportunity for Chanora
### ReSpeak
- **Status:** Active, 16 repositories, tsclientlib is the core library
- **Chanora dependency:** Uses `tsclientlib` (forked) for protocol implementation
- **Key repos:** `tsclientlib` (protocol), `tsdeclarations` (protocol spec), `quicklz` (compression)
- **Recommendation:** Continue using tsclientlib, consider upstreaming p256 fix
### yat.qa (YaTQA)
- **What it is:** Windows GUI tool for TeamSpeak 3 ServerQuery management (NOT a testing/QA site)
- **Relevance:** Low — management tool, not testing framework
- **Useful resources:** Unofficial ServerQuery docs, server error codes, permission IDs, anti-flood mechanics
---
## 16. Recommendations
### P0 — Critical (do immediately)
1. **Add `chanora_bridge` tests** — 0/5 modules tested, FFI boundary is highest risk
2. **Replace `Mutex::lock().unwrap()` in engine.rs** — ~39 calls, crash risk
3. **Update implementation-status document** — 14 days stale, primary evidence source
4. **Allocate SysRS-258310 in SysDes** — breaks traceability chain
5. **Fix traceability matrix SRS numbering** — IDs don't match actual SRS
### P1 — High Priority (next sprint)
6. **Add Android/Windows/macOS CI builds** — currently only Ubuntu
7. **Add missing bridge functions**`get_hard_mute()`, `get_connection_state()`, `reconnect()`
8. **Add README files to all 8 crates + core**
9. **Split engine.rs** (3,244 lines) into smaller modules
10. **Add requirements for implemented features** — poke, file transfer, hard-mute, cache
### P2 — Medium Priority
11. **Extract platform backends from chanora_audio** — compile-time benefit
12. **Add doc comments to chanora_audio** (24% → 80% target)
13. **Fix `pick_client_version()` hardcoded to Windows**
14. **Implement per-user volume/mute persistence**
15. **Add UI settings store**
### P3 — Low Priority
16. **Clean up dead code** (25+ items)
17. **Deduplicate code patterns** (10 major patterns)
18. **Fix broken doc links** (create LICENSE files)
19. **Link orphaned documentation**
20. **Add protocol probe tool**
---
*Generated by codebase analysis agents on 2026-06-11*
+157
View File
@@ -0,0 +1,157 @@
# Issue History Analysis
**Date:** 2026-06-11
**Scope:** All git history, PRs, and issue patterns
---
## 1. Issue Categories
### iOS Audio (15+ issues) — Most Problematic Area
| Commit | Issue | Root Cause | Fix |
|--------|-------|------------|-----|
| #42 (OPEN) | AVAudioSession not activated before connect | Auto-join spawns VPIO before session in playAndRecord mode | Activate session BEFORE rust.connect |
| #41 | iOS Debug builds blocked | Debug.xcconfig missing `-u` flags; verify script checked wrong Mach-O | Mirror Release xcconfig; check correct dylib |
| #38 | Audio session dead after "already in channel" | Server response 0x0302 treated as failure | Keep session active on already-in-channel |
| #33 | App kills other apps' audio while idle | Held `.playAndRecord` from launch | Idle baseline now `.ambient`; escalate during calls |
| #28 | False underrun counters when muted | `peak_i16 == 0` check didn't gate on muted state | Gate underrun on `!muted` |
| #26 | Silero exports stripped in Xcode Archive | `ld -dead_strip` removed unreferenced symbols | `-exported_symbol` whitelist in xcconfigs |
| #10 | Voice join stuck at "Connecting" | Audio startup blocking connect critical path | Move audio startup off connect path |
**Pattern:** iOS AVAudioSession has 7+ interacting configuration dimensions. Each fix reveals the next layer.
### Realtime Thread Safety (5+ issues)
| Commit | Issue | Root Cause | Fix |
|--------|-------|------------|-----|
| #20 | Android audio output stutter | Mutex contention in audio callback | Oboe config tuning + lock-free callback |
| #27 | macOS audio event queue | Lock contention in render path | Lock-free ArrayQueue pattern |
| engine.rs:39 | ~39 `unwrap()` calls on Mutex | Poisoned mutex will panic engine | Need `try_lock()` or `parking_lot::Mutex` |
**Pattern:** Realtime audio callbacks must NEVER use `Mutex::lock()` — always `try_lock()` with silence fallback.
### Build Toolchain (4+ issues)
| Commit | Issue | Root Cause | Fix |
|--------|-------|------------|-----|
| #41 | iOS Debug builds fail | Debug.xcconfig diverged from Release | Mirror Release settings in Debug |
| #26 | Silero exports stripped | `ld -dead_strip` + install-time `strip` | `-exported_symbol` whitelist + `STRIP_STYLE = non-global` |
| #37 | MSVC CRT mismatch | cmake linking wrong CRT | `cmake-msvc-release-crt.cmd` |
**Pattern:** Always verify with `xcodebuild archive`, not just `flutter build`.
### Protocol Issues (3+ issues)
| Commit | Issue | Root Cause | Fix |
|--------|-------|------------|-----|
| #16 | Speaking state incorrect for non-self clients | Client profiles not refreshed before mapping | Refresh non-self profiles |
| #16 | Server-query clients missing | Missing protocol DTO fields | Add ping deviation through full stack |
| #5 | UI crashes on unexpected state transitions | Missing null/mounted guards | Guard deferred side effects |
**Pattern:** Every protocol command should surface errors to UI — fire-and-forget hides failures.
---
## 2. PR Development Patterns
### PR Summary (42 total, 34 merged, 8 closed)
| Type | Count | Merged |
|------|-------|--------|
| Features | 14 | 10 |
| Bug fixes | 12 | 12 |
| Refactoring | 4 | 3 |
| Documentation | 1 | 1 |
| Build/Chore | 4 | 4 |
| Performance | 1 | 1 |
| Dependency | 1 | 0 |
| Closed (superseded) | 5 | 0 |
### Most Active Areas
| Area | PR Count | Notes |
|------|----------|-------|
| iOS Audio | 10+ | Most recurring issues |
| macOS Audio | 5 | Lock-free architecture |
| Voice/VAD | 6 | CoreML, ONNX, WebRTC |
| Flutter UI | 8 | Event-driven, responsive |
| Protocol/State | 5 | State reducers, events |
### Quality Patterns
1. **Self-review via "Oracle" agent** — catches real issues pre-merge
2. **Local CI mirroring** — GitHub Actions billing broken
3. **PR stacking** — need better branch management workflow
4. **Follow-up fix pattern** — PRs stay focused
---
## 3. Test Cases for Known Issues
### iOS Audio Session Lifecycle
```dart
// Test: Session activates before connect
test('voice_join activates AVAudioSession before starting voice', () async {
// Verify session state transitions: ambient → playAndRecord → ambient
});
// Test: Already-in-channel response keeps session active
test('voice_join keeps session active on already-in-channel response', () async {
// Mock server response code 0x0302
// Verify session remains in playAndRecord state
});
// Test: Idle app doesn't kill other audio
test('app uses ambient mode when not in voice channel', () async {
// Verify session mode is .ambient + .mixWithOthers when idle
});
```
### Realtime Thread Safety
```rust
// Test: Audio callback doesn't panic on poisoned mutex
#[test]
fn audio_callback_survives_poisoned_mutex() {
// Verify try_lock fallback produces silence, not panic
}
```
### Protocol Error Surfacing
```dart
// Test: Server errors surface to UI
test('server error messages are forwarded as UI events', () async {
// Mock protocol error
// Verify UI receives error event
});
```
### Build Verification
```bash
# Test: iOS Debug build succeeds
xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner -configuration Debug build
# Test: iOS Archive build preserves Silero exports
xcodebuild archive -workspace ios/Runner.xcworkspace -scheme Runner
# Verify: nm -g archive.xcarchive/Products/Applications/Chanora.app/Chanora | grep silero
```
---
## 4. Root Cause Patterns
| Pattern | Frequency | Prevention |
|---------|-----------|------------|
| iOS AVAudioSession lifecycle | 8+ PRs | Document state machine, add integration tests |
| Mutex on realtime threads | 5+ issues | Use `try_lock()` or `parking_lot::Mutex` |
| Xcode Archive vs build divergence | 4+ issues | Always test with `xcodebuild archive` |
| Silent protocol failures | 3+ issues | Surface all errors to UI |
| Platform-specific build quirks | 5+ issues | CI on all target platforms |
---
*Generated by git history analysis agents on 2026-06-11*
@@ -0,0 +1,721 @@
# Master TODO List
**Date:** 2026-06-11
**Branch:** `docs/codebase-analysis-v2`
**Sources:**
- `docs/governance/codebase-analysis-2026-06-11.md` — main codebase analysis
- `docs/governance/issue-history-analysis.md` — git history patterns and root causes
- `docs/verification/test-environment-requirements.md` — test environment setup needs
- `docs/references/external-research-2026-06-11.md` — external research findings
---
## Summary
| Category | P0 | P1 | P2 | P3 | Total |
|---|---|---|---|---|---|
| Dead Code Removal | 0 | 0 | 0 | 5 | 5 |
| Code Quality | 2 | 2 | 3 | 2 | 9 |
| Test Coverage | 2 | 3 | 2 | 0 | 7 |
| Architecture | 0 | 2 | 2 | 1 | 5 |
| Documentation | 3 | 2 | 3 | 2 | 10 |
| Missing APIs / Requirements | 0 | 2 | 7 | 2 | 11 |
| Infrastructure | 0 | 3 | 5 | 1 | 9 |
| External Research Follow-up | 0 | 1 | 2 | 2 | 5 |
| Protocol Implementation | 0 | 2 | 8 | 3 | 13 |
| Reference Document Improvements | 0 | 0 | 9 | 1 | 10 |
| Additional Traceability Gaps | 0 | 0 | 3 | 1 | 4 |
| **Total** | **7** | **17** | **44** | **20** | **88** |
---
## 1. Dead Code Removal
### TODO-001 — Remove dead AudioFrame structs
- **Priority:** P3
- **Source:** codebase-analysis §5
- **Description:** `AudioFrame10ms` and `AudioFrame20ms` structs and `AudioFrame10ms::dbfs()` are never referenced outside tests. Safe to delete.
- **Effort:** S
- **Dependencies:** None
### TODO-002 — Remove dead `disable_failed_vad_backend()`
- **Priority:** P3
- **Source:** codebase-analysis §5
- **Description:** Function only called in tests. Remove from production code.
- **Effort:** S
- **Dependencies:** None
### TODO-003 — Remove unused `share_plus` import
- **Priority:** P3
- **Source:** codebase-analysis §5
- **Description:** `import 'package:share_plus/share_plus.dart'` in main.dart:55 — `Share` class never used.
- **Effort:** S
- **Dependencies:** None
### TODO-004 — Audit and remove likely-dead functions (16 across 5 crates)
- **Priority:** P3
- **Source:** codebase-analysis §2, §5
- **Description:** 16 functions have no production callers. From §5: 11 AudioEngine/TransmitModeSelector/ReleaseTailTimer methods (output_muted, output_gain, capture_active, transmit_gate, set_transmit_active, android_diagnostics, gate, in_channel, ptt_held, cancel, arm) + `AudioEngine::frames_sent()` and `AudioEngine::frames_received()`. From §2: `ChanoraSession::protocol_events_snapshot()` (chanora_core), `ServerState::replace_from_snapshot()/channel_count()/client_count()` (chanora_state), `BookmarkStore::upsert_or_add()` (chanora_storage), `normalize_args()/validate_args()` (chanora_resolver), `HpfProcessor::process_sample()` (chanora_audio), `CoreMlWorker::reset_state()` (chanora_audio), `PttCapabilityLevel::is_global()` (chanora_audio), `handle_media_services_reset_with_route()/handle_interruption_began()` (chanora_bridge). Confirm with call-graph then remove or wire.
- **Effort:** M
- **Dependencies:** None
### TODO-005 — Decide fate of `#[allow(dead_code)]` items
- **Priority:** P3
- **Source:** codebase-analysis §5
- **Description:** `AudioCommand::RemoveClient` has TODO to wire to client disconnect path. Three Android-only modules are conditionally dead. Decide: wire, remove, or keep annotated.
- **Effort:** M
- **Dependencies:** None
---
## 2. Code Quality
### TODO-006 — Replace ~39 `Mutex::lock().unwrap()` calls in engine.rs
- **Priority:** P0
- **Source:** codebase-analysis §6, issue-history §1 (realtime thread safety)
- **Description:** Production `unwrap()` on Mutex locks will panic on poisoned mutex. Replace with `try_lock()` + silence fallback or `parking_lot::Mutex`.
- **Effort:** L
- **Dependencies:** None
### TODO-007 — Audit 8 `unsafe impl Send/Sync` blocks
- **Priority:** P0
- **Source:** codebase-analysis §6
- **Description:** 8 unsafe Send/Sync impls across various files. Each needs safety proof comment and review.
- **Effort:** M
- **Dependencies:** None
### TODO-008 — Resolve 14 TODO/FIXME/HACK entries in codebase
- **Priority:** P1
- **Source:** codebase-analysis §6
- **Description:** 14 occurrences across engine.rs, mobile_voice_backend.rs, audio_event_queue.rs, audio_lifecycle_service.dart, poke_notification_service.dart. Address or convert to tracked issues.
- **Effort:** L
- **Dependencies:** None
### TODO-009 — Audit 23 `unimplemented!("")` in frb_generated.rs
- **Priority:** P1
- **Source:** codebase-analysis §6
- **Description:** Flutter-Rust Bridge generated stubs contain 23 `unimplemented!("")` calls that will panic at runtime if hit. Audit and implement or guard each one.
- **Effort:** M
- **Dependencies:** None
### TODO-010 — Fix `pick_client_version()` hardcoded to Windows
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** `adapter.rs:141` returns Windows client version for ALL platforms. Must return platform-appropriate version strings.
- **Effort:** S
- **Dependencies:** None
### TODO-011 — Fix Android stubs returning None/Ok(None)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** `log_file_path()` on Android returns `None` (api.rs:350-356), `keyring_load()` on Android returns `Ok(None)` always. These silently hide functionality gaps.
- **Effort:** S
- **Dependencies:** None
### TODO-012 — Deduplicate AudioProcessingConfig construction (4x)
- **Priority:** P2
- **Source:** codebase-analysis §7
- **Description:** AudioProcessingConfig is constructed identically in 4 places in Dart. Extract to factory or shared config.
- **Effort:** S
- **Dependencies:** None
### TODO-013 — Deduplicate audio processing toggle UI
- **Priority:** P3
- **Source:** codebase-analysis §7
- **Description:** Audio processing toggle UI duplicated across 2 Dart files (~150 lines). Extract shared widget.
- **Effort:** S
- **Dependencies:** None
### TODO-014 — Deduplicate low-priority code patterns (7 patterns)
- **Priority:** P3
- **Source:** codebase-analysis §7
- **Description:** PttCapability event construction (1 Rust, ~15 lines), DnsFailed/ServerRejected error mirroring (2 Rust, ~20 lines), host normalization (3 Rust files), create_dir_all (2 Rust files), Io(String) variant (3 crates), StateEvent/Delta mirror (1 Rust file), platform detection scatter (6 Dart, ~35 checks).
- **Effort:** M
- **Dependencies:** None
---
## 3. Test Coverage
### TODO-015 — Add chanora_bridge tests (0/5 modules tested)
- **Priority:** P0
- **Source:** codebase-analysis §3
- **Description:** The Flutter-Rust FFI boundary has zero tests. This is the highest-risk untested area. Need unit tests for all 5 bridge modules.
- **Effort:** XL
- **Dependencies:** None
### TODO-016 — Add poisoned-mutex survival test for audio callbacks
- **Priority:** P0
- **Source:** issue-history §3
- **Description:** Verify `try_lock()` fallback produces silence rather than panic when mutex is poisoned. Directly tests the fix for TODO-006.
- **Effort:** M
- **Dependencies:** TODO-006
### TODO-017 — Add Flutter widget tests for untested core UI
- **Priority:** P1
- **Source:** codebase-analysis §3
- **Description:** `voice_bar`, `voice_settings`, `connect_widgets` modules have no tests. These are core UI components at 56% widget coverage.
- **Effort:** L
- **Dependencies:** None
### TODO-018 — Add chanora_protocol dto module tests
- **Priority:** P1
- **Source:** codebase-analysis §3
- **Description:** Protocol dto module is untested. Serialization bugs here cause silent protocol failures.
- **Effort:** M
- **Dependencies:** None
### TODO-019 — Add chanora_core events module tests
- **Priority:** P1
- **Source:** codebase-analysis §3
- **Description:** Events module is untested (80% coverage overall, but events gap). Event system is central to app behavior.
- **Effort:** M
- **Dependencies:** None
### TODO-020 — Add iOS audio session lifecycle integration tests
- **Priority:** P2
- **Source:** issue-history §3
- **Description:** Test session transitions (ambient → playAndRecord → ambient), already-in-channel response handling, idle audio mode. 15+ historical issues justify this.
- **Effort:** L
- **Dependencies:** TODO-030 (device test infrastructure)
### TODO-021 — Add protocol error surfacing tests
- **Priority:** P2
- **Source:** issue-history §3
- **Description:** Verify server errors are forwarded as UI events, not silently swallowed. Fire-and-forget hides failures.
- **Effort:** M
- **Dependencies:** None
---
## 4. Architecture
### TODO-022 — Split engine.rs (3,244 lines) into smaller modules
- **Priority:** P1
- **Source:** codebase-analysis §6, §12
- **Description:** Monolithic audio engine file. Split into engine core, capture, render, transmit, diagnostics modules.
- **Effort:** L
- **Dependencies:** None
### TODO-023 — Split main.dart (2,910 lines) into smaller modules
- **Priority:** P1
- **Source:** codebase-analysis §6
- **Description:** Monolithic Flutter entry point. Extract into feature-based modules/screens.
- **Effort:** L
- **Dependencies:** None
### TODO-024 — Extract platform backends from chanora_audio into separate crates
- **Priority:** P2
- **Source:** codebase-analysis §12
- **Description:** Split into `chanora_audio` (core ~8K lines), `chanora_audio_android` (~3.2K), `chanora_audio_apple` (~1.4K), `chanora_audio_desktop` (~3.5K). Platform backends are already cfg-gated.
- **Effort:** XL
- **Dependencies:** TODO-022 (split engine.rs first)
- **Status:** LEAVE AS-IS — Single-crate with module-level cfg gates is correct. Splitting adds dependency complexity with no benefit. (2026-06-11)
### TODO-025 — Reduce `.map_err(format!)` boilerplate across 5 Rust crates
- **Priority:** P2
- **Source:** codebase-analysis §7
- **Description:** ~85 repeated closure patterns. Introduce a `WrapErr` trait or macro to reduce boilerplate.
- **Effort:** L
- **Dependencies:** None
### TODO-026 — Reduce adapter.rs (2,504 lines) and api.rs (2,432 lines)
- **Priority:** P3
- **Source:** codebase-analysis §6
- **Description:** Both files exceed 2,400 lines. Consider splitting by feature area.
- **Effort:** L
- **Dependencies:** TODO-022, TODO-023
- **Status:** LEAVE AS-IS — adapter.rs has deep coupling in connection_task event loop; api.rs is already well-organized with section comments. Splitting would break internal type visibility. (2026-06-11)
---
## 5. Documentation
### TODO-027 — Update implementation-status document
- **Priority:** P0
- **Source:** codebase-analysis §13
- **Description:** `docs/implementation-status-2026-05-28.md` is 14 days stale, missing v0.3.0 features. This is the primary evidence source for verification.
- **Effort:** M
- **Dependencies:** None
### TODO-028 — Allocate SysRS-258310 in SysDes
- **Priority:** P0
- **Source:** codebase-analysis §13
- **Description:** 53 SysRS requirements lack SysDes allocation, breaking the traceability chain from requirements to design.
- **Effort:** L
- **Dependencies:** None
### TODO-029 — Fix traceability matrix SRS numbering mismatches
- **Priority:** P0
- **Source:** codebase-analysis §13
- **Description:** SRS IDs in traceability matrix don't match actual SRS document. Needs manual reconciliation.
- **Effort:** M
- **Dependencies:** None
### TODO-030 — Add README files to 8 crates + core
- **Priority:** P1
- **Source:** codebase-analysis §4
- **Description:** Only `chanora_resolver` has a README. The other 9 directories need one describing purpose, architecture, and public API.
- **Effort:** M
- **Dependencies:** None
### TODO-031 — Improve chanora_audio doc comments (24% → 80%)
- **Priority:** P2
- **Source:** codebase-analysis §4
- **Description:** Largest crate (168 pub fn, 18,895 lines) at only 24% documentation. Target 80% coverage.
- **Effort:** XL
- **Dependencies:** None
### TODO-032 — Fix 6 broken doc links (create LICENSE files)
- **Priority:** P3
- **Source:** codebase-analysis §8
- **Description:** All 6 broken links point to missing `LICENSE-APACHE` and `LICENSE-MIT` files at project root. LICENSE files have been created — verify links resolve.
- **Effort:** S
- **Dependencies:** None
### TODO-033 — Link 14 orphaned documentation files
- **Priority:** P3
- **Source:** codebase-analysis §8
- **Description:** 14 files in `docs/superpowers/plans/` and `docs/superpowers/specs/` are not linked from any index document.
- **Effort:** S
- **Dependencies:** None
### TODO-034 — Update SDD (docs/architecture/sdd.md)
- **Priority:** P2
- **Source:** codebase-analysis §13
- **Description:** Software Design Description scored 5/10 freshness. Missing many modules added since initial write.
- **Effort:** L
- **Dependencies:** None
### TODO-035 — Update SAD (docs/architecture/sad.md)
- **Priority:** P2
- **Source:** codebase-analysis §13
- **Description:** Software Architecture Description scored 5/10 freshness. Missing components and interfaces.
- **Effort:** L
- **Dependencies:** None
### TODO-036 — Update Verification Master Plan
- **Priority:** P1
- **Source:** codebase-analysis §13
- **Description:** Verification Master Plan scored 6/10, stale evidence sources and missing verification methods.
- **Effort:** M
- **Dependencies:** None
---
## 6. Missing APIs / Requirements
### TODO-037 — Add missing bridge read-back functions
- **Priority:** P1
- **Source:** codebase-analysis §11
- **Description:** `is_hard_muted()`, `get_output_gain()`, `get_client_volume()`, `connection_state()`, `reconnect()` are missing from bridge. UI must derive state from events only.
- **Effort:** M
- **Dependencies:** None
### TODO-038 — Write requirements for implemented features
- **Priority:** P1
- **Source:** codebase-analysis §13
- **Description:** Poke, file transfer, hard-mute, and cache features exist in code but have no SysRS/SRS requirements. Breaks traceability.
- **Effort:** L
- **Dependencies:** None
### TODO-039 — Implement UI settings persistence (SysRS-143, SRS-087)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Settings persistence is a documented requirement with no implementation.
- **Effort:** M
- **Dependencies:** None
### TODO-040 — Implement per-user mute/volume persistence (SysRS-145/144, SRS-088)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Per-user mute and volume persistence requirements are partially implemented. Needs completion.
- **Effort:** M
- **Dependencies:** None
### TODO-041 — Implement notification permission handling (SysRS-166, SRS-109)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Notification permission requirement has no implementation.
- **Effort:** M
- **Dependencies:** None
### TODO-042 — Complete recent server management (SysRS-141, SRS-085)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Recent server management is partially implemented. Needs completion per spec.
- **Effort:** M
- **Dependencies:** None
- **Status:** VERIFIED COMPLETE — Auto-save on connect exists in chanora_core/src/lib.rs:629-650. Bookmark list UI shows all bookmarks with connect/delete. _reloadBookmarks() called on disconnect. (2026-06-11)
### TODO-043 — Implement event replay tool (SysRS-171, SRS-098)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Event replay tool is a documented requirement with no implementation.
- **Effort:** L
- **Dependencies:** None
### TODO-044 — Implement audio processing test tool (SysRS-074, SRS-083)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Audio processing test tool is a documented requirement with no implementation.
- **Effort:** L
- **Dependencies:** None
- **Status:** IMPLEMENTED — tools/audio-test/ binary for DSP pipeline benchmarking. (2026-06-11)
- **Priority:** P2
- **Source:** codebase-analysis §11
- **Description:** Input validation is partially implemented. Needs completion per spec.
- **Effort:** M
- **Dependencies:** None
### TODO-046 — Implement audio loopback test tool (SysRS-073, SRS-083)
- **Priority:** P3
- **Source:** codebase-analysis §11
- **Description:** Audio loopback test tool is a documented requirement with no implementation.
- **Effort:** L
- **Dependencies:** TODO-054 (audio loopback harness)
### TODO-047 — Implement protocol probe tool (SysRS-128, SRS-123)
- **Priority:** P3
- **Source:** codebase-analysis §11
- **Description:** Protocol probe tool is a documented requirement with no implementation.
- **Effort:** L
- **Dependencies:** None
- **Status:** IMPLEMENTED — tools/protocol-probe/ binary for server capability probing. (2026-06-11)
### TODO-048 — Add Android CI build job
- **Priority:** P1
- **Source:** test-environment-requirements §9 Phase 1
- **Description:** No Android CI build exists. Add NDK + cargo-ndk + multi-ABI build to GitHub Actions.
- **Effort:** M
- **Dependencies:** None
### TODO-049 — Add Windows and macOS CI build jobs
- **Priority:** P1
- **Source:** test-environment-requirements §9 Phase 1
- **Description:** Windows and macOS builds are not in CI. Add windows-latest and macOS runners for build verification.
- **Effort:** M
- **Dependencies:** None
### TODO-050 — Add xcodebuild Archive verification to CI
- **Priority:** P1
- **Source:** issue-history §1
- **Description:** 4+ historical issues caused by `flutter build` passing but `xcodebuild archive` failing. Add xcodebuild archive step to CI.
- **Effort:** M
- **Dependencies:** None
### TODO-051 — Enable clippy as blocking CI check
- **Priority:** P2
- **Source:** test-environment-requirements §9 Phase 1
- **Description:** Clippy is currently advisory only. Make it a blocking PR gate.
- **Effort:** S
- **Dependencies:** None
### TODO-052 — Add Linux multi-distro build matrix
- **Priority:** P2
- **Source:** test-environment-requirements §9 Phase 1
- **Description:** Add Docker-based CI matrix for Ubuntu, Fedora, Arch to catch distro-specific PipeWire/Portal/DBus issues.
- **Effort:** M
- **Dependencies:** None
### TODO-053 — Add `cargo llvm-cov` coverage reporting
- **Priority:** P2
- **Source:** test-environment-requirements §9 Phase 1
- **Description:** No code coverage reporting exists. Add `cargo llvm-cov` to CI for trend tracking.
- **Effort:** M
- **Dependencies:** None
### TODO-054 — Implement audio loopback test harness
- **Priority:** P2
- **Source:** test-environment-requirements §9 Phase 2
- **Description:** Build virtual audio device loopback for CI audio quality testing. Use BlackHole (macOS), VB-Audio (Windows), snd-aloop (Linux).
- **Effort:** L
- **Dependencies:** TODO-048, TODO-049
### TODO-055 — Implement test environment Phase 2 (device integration)
- **Priority:** P2
- **Source:** test-environment-requirements §9 Phase 2
- **Description:** Set up self-hosted runners for device tests, Flutter integration tests with platform channels, Firebase Test Lab for Android, network condition test suite.
- **Effort:** XL
- **Dependencies:** TODO-048, TODO-049
### TODO-056 — Implement test environment Phase 3 (full automation)
- **Priority:** P3
- **Source:** test-environment-requirements §9 Phase 3
- **Description:** Benchmark regression gates, iOS TestFlight automation, signed build automation, weekly audio quality regression, Android multi-device testing.
- **Effort:** XL
- **Dependencies:** TODO-055
---
## 8. External Research Follow-up
### TODO-057 — Upstream p256 coordinate padding fix to ReSpeak
- **Priority:** P1
- **Source:** external-research §2 (ReSpeak)
- **Description:** Chanora forks `tsclientlib` for a p256 fix. Submit PR upstream to reduce fork maintenance burden.
- **Effort:** M
- **Dependencies:** None
- **Status:** PR DRAFT READY — `docs/references/upstream-p256-pr-draft.md` (2026-06-11)
### TODO-058 — Monitor tsdeclarations for TS5 protocol updates
- **Priority:** P2
- **Source:** external-research §2 (ReSpeak)
- **Description:** No full TS5 client protocol exists yet. Watch `tsdeclarations` repo for updates that may affect Chanora compatibility.
- **Effort:** S
- **Dependencies:** None (ongoing)
- **Status:** MONITORING — No TS5 updates detected. GitHub watch recommended. (2026-06-11)
### TODO-059 — Build auto-reconnect logic internally
- **Priority:** P2
- **Source:** external-research §2 (ReSpeak)
- **Description:** tsclientlib explicitly notes auto-reconnect is "not yet there." Chanora must build this independently.
- **Evidence:** `core/chanora_core/src/lib.rs:1722-2123` — Supervisor with:
- Exponential backoff: `BACKOFF_SCHEDULE = [1, 2, 5, 15, 30, 60]` seconds
- Watchdog: 5s interval, 4s probe timeout, 3 max misses
- Network state awareness: pre-charges watchdog on offline, resets on online
- Audio re-attachment via `sup_inner` after successful reconnect
- Cancellation via `cancel_rx` oneshot on user disconnect
- Broadcasts `SessionEvent::Reconnecting { attempt, delay_secs }` to UI
- No max attempt limit (indefinite retry — correct for voice client)
- Cross-verified against ReSpeak §1.10 (exponential backoff for packet timeouts)
- **Effort:** L
- **Dependencies:** None
- **Status:** VERIFIED COMPLETE — supervisor with exponential backoff, watchdog, network awareness (2026-06-11)
### TODO-060 — Extract server error codes from YaTQA for error handling
- **Priority:** P3
- **Source:** external-research §3 (YaTQA)
- **Description:** YaTQA's `/ressourcen/` section has comprehensive unofficial server error codes. Extract for Chanora error handling reference.
- **Effort:** S
- **Dependencies:** None
- **Status:** VERIFIED COMPLETE — 170+ error codes in yatqa-offline-reference.md §9, cross-reference with ReSpeak in §13 (2026-06-11)
### TODO-061 — Extract anti-flood/rate-limiting reference from YaTQA
- **Priority:** P3
- **Source:** external-research §3 (YaTQA)
- **Description:** YaTQA documents anti-flood mechanics and rate limiting. Use as reference when implementing Chanora's rate limiting.
- **Effort:** S
- **Dependencies:** None
- **Status:** PARTIALLY COMPLETE — summary tiers in yatqa-offline-reference.md §5; detailed action table still needs backfill (see TODO-081)
---
## 9. Protocol Implementation (from Reference Documents)
### TODO-062 — Implement DNS resolution chain (SRV→TSDNS→DNS)
- **Priority:** P1
- **Source:** yatqa-offline-reference §8.5
- **Description:** YaTQA documents the full DNS resolution order: SRV `_ts3._udp` → SRV TSDNS → TSDNS TCP port 41144 → DNS AAAA/A. First successful resolution wins, NO fallback on connection failure. Critical for connection reliability.
- **Evidence:** `crates/chanora_resolver/src/lib.rs:178-387` — Full chain implemented:
- Direct DNS resolution (`resolve_dns`, line 594)
- TSDNS SRV resolution (`resolve_tsdns_srv_candidates`, line 414)
- TSDNS TCP resolution (`resolve_tsdns_tcp_candidates`, line 454)
- First-wins semantics with `return Ok(resolution)` on first success
- No fallback on failure (errors accumulate, bail at end)
- Cross-verified against YaTQA §8.5 resolution order
- **Effort:** M
- **Dependencies:** None
- **Status:** VERIFIED COMPLETE — Full chain implemented: DNS, TSDNS SRV, TSDNS TCP in chanora_resolver/src/lib.rs:178-387. First-wins semantics. (2026-06-11)
### TODO-063 — Verify tsclientlib encoding handling (UTF-8 vs UCS-2 vs CESU-8)
- **Priority:** P1
- **Source:** yatqa-offline-reference §1.3
- **Description:** TS claims UTF-8 but uses UCS-2 (BMP only). Mobile apps use CESU-8. Server 3.2.0+ adds partial emoji support. tsclientlib/tsproto delivers Rust `String` (UTF-8) via `str::from_utf8()` at the protocol layer (ReSpeak §1.8.3 confirms: "Command string encoded in UTF-8"). If encoding bugs exist, the fix belongs in the EdisonJwa/tsclientlib fork, not in chanora_protocol.
- **Verification:** Confirm tsclientlib handles UCS-2 correctly. Document encoding behavior in protocol-implementation-traps.md (TODO-070).
- **Effort:** S (verification + documentation)
- **Status:** VERIFIED — strict UTF-8 via `str::from_utf8()`, no UCS-2/CESU-8 code exists, non-UTF-8 causes errors not corruption (2026-06-11)
- **Evidence:** `docs/references/encoding-verification.md`
- **Dependencies:** TODO-070 (protocol traps doc)
### TODO-064 — Implement client-side anti-flood point awareness
- **Priority:** P2
- **Source:** yatqa-offline-reference §5
- **Description:** YaTQA documents complete point costs per action (channelsubscribe=158pts, connect=80pts, etc.). Design Chanora's client-side anti-flood awareness to avoid accidental server bans. Tick-based point reduction model.
- **Effort:** M
- **Dependencies:** None
### TODO-065 — Implement badge fetching and display
- **Priority:** P2
- **Source:** yatqa-offline-reference §8.9, respeak-protocol-reference §5
- **Description:** Badges fetched from `badges-content.teamspeak.com` in Protobuf format, cached locally, refreshed every 24h. Chanora needs badge Protobuf parsing, caching, and UI display.
- **Effort:** L
- **Dependencies:** None
### TODO-066 — Implement TSDNS protocol support
- **Priority:** P2
- **Source:** yatqa-offline-reference §8.4
- **Description:** TSDNS protocol: TCP port 41144, lowercase domain + magic bytes. Required for `ts3server://` URL resolution and server bookmark handling.
- **Effort:** M
- **Dependencies:** TODO-062
- **Status:** VERIFIED COMPLETE — TSDNS TCP fully implemented in chanora_resolver/src/lib.rs:529-563 (port 41144, magic bytes, TCP query with 3s timeout). (2026-06-11)
### TODO-067 — Implement TS3 file transfer protocol
- **Priority:** P2
- **Source:** yatqa-offline-reference §8.3, external-research §2 (ReSpeak gap)
- **Description:** Raw file transfer: send key from `ftinitupload`/`ftinitdownload` to server IP:port, then raw data. No escaping. ReSpeak has no file transfer implementation — Chanora must build this independently.
- **Effort:** L
- **Dependencies:** None
- **Status:** VERIFIED COMPLETE — FileTransferService in chanora_core/src/file_transfer.rs (344 lines) + BlobCache in chanora_cache. ftinitdownload, TCP data, content-addressed cache, request coalescing, rate limiting. (2026-06-11)
### TODO-068 — Evaluate tsdeclarations machine-readable files for code generation
- **Priority:** P2
- **Source:** respeak-protocol-reference §9
- **Description:** Messages.toml, Book.toml, Enums.toml, MessagesToBook.toml, BookToMessages.toml could automate protocol struct and event generation. Evaluate feasibility for Chanora's build pipeline.
- **Effort:** M
- **Dependencies:** None
### TODO-069 — Extract Permission IDs into Chanora's permission system
- **Priority:** P2
- **Source:** respeak-protocol-reference §3, yatqa-offline-reference §1.2
- **Description:** Both ReSpeak (tsdeclarations) and YaTQA provide comprehensive permission ID lists with Skip/Negate/Grant logic. Map these to Chanora's permission implementation for full TS3 compatibility.
- **Effort:** L
- **Dependencies:** None
### TODO-070 — Document protocol implementation traps
- **Priority:** P2
- **Source:** yatqa-offline-reference §1.4, §1.5, §3.1
- **Description:** Several implementation traps documented in YaTQA: icon IDs signed/unsigned mismatch, avatar flag is MD5 hash not boolean, channel subscription limited to ONE at a time, line terminator is `0x0A 0x0D` (reversed Windows). Compile into Chanora developer notes.
- **Effort:** S
- **Dependencies:** None
### TODO-071 — Handle IPv6 blacklist canonicalization bug
- **Priority:** P3
- **Source:** yatqa-offline-reference §8.7
- **Description:** TS3 server 3.1.6-3.1.7 has IPv6 canonicalization bug in Blacklist2 protocol. Chanora should be aware when connecting to older servers.
- **Effort:** S
- **Dependencies:** None
- **Status:** DOCUMENTED — trap #6 in protocol-implementation-traps.md (2026-06-11)
### TODO-072 — Establish connection/message performance benchmarks
- **Priority:** P3
- **Source:** respeak-protocol-reference §10
- **Description:** tsclientlib benchmarks: ~199ms connect, ~189µs/message. Use as baseline for Chanora's performance testing.
- **Effort:** S
- **Dependencies:** None
- **Status:** DOCUMENT CREATED — `docs/references/performance-benchmarks.md` (2026-06-11)
### TODO-073 — Assess web client feasibility
- **Priority:** P3
- **Source:** teaspeak-offline-reference §10, external-research §1
- **Description:** TeaSpeak's web client was a compelling installation-less option. Evaluate whether a future Chanora web client makes sense. Not current scope.
- **Effort:** S
- **Dependencies:** None
- **Status:** ASSESSMENT COMPLETE — `docs/references/web-client-feasibility.md`; verdict: defer indefinitely (UDP transport blocker) (2026-06-11)
---
## 10. Reference Document Improvements
### TODO-074 — Fix respeak reference: malformed badge table row
- **Priority:** P2
- **Source:** respeak-protocol-reference review (line 1309)
- **Description:** MIFCOM badge row has 6 columns (5 pipes) but table header has 5 columns. "Entered Performance" is a misplaced column. Fix table formatting.
- **Effort:** S
- **Dependencies:** None
### TODO-075 — Fix respeak reference: section numbering placeholder
- **Priority:** P2
- **Source:** respeak-protocol-reference review (line 764)
- **Description:** Section "4.? Differences between Query and Full Client" has a `?` placeholder. Assign correct section number (4.9 or similar).
- **Effort:** S
- **Dependencies:** None
### TODO-076 — Fix respeak reference: duplicate section numbering
- **Priority:** P2
- **Source:** respeak-protocol-reference review
- **Description:** Both the protocol spec section and reference data sections start at `# 2.`, making "Section 2" ambiguous. Separate into Part I (Protocol) and Part II (Reference Data) or renumber.
- **Effort:** S
- **Dependencies:** None
### TODO-077 — Resolve teaspeak reference: godmode permission contradiction
- **Priority:** P2
- **Source:** teaspeak-offline-reference review (lines 401 vs 414)
- **Description:** `b_virtualserver_select_godmode` listed as active permission in "Other Permissions" table (line 401) AND as "removed" in "Removed Permissions" table (line 414). Resolve: remove from one location or clarify version-based behavior.
- **Effort:** S
- **Dependencies:** None
### TODO-078 — Resolve teaspeak reference: music bot command contradiction
- **Priority:** P2
- **Source:** teaspeak-offline-reference review (lines 499 vs 1229)
- **Description:** Music bot queue commands (`musicbotqueuelist`, `musicbotqueueadd`, etc.) listed in active Music Bot Query Commands table (lines 499-501) AND in "Removed Commands" section (lines 1229-1236). Clarify which version removed them.
- **Effort:** S
- **Dependencies:** None
### TODO-079 — Add Chanora relevance section to teaspeak reference
- **Priority:** P2
- **Source:** teaspeak-offline-reference review
- **Description:** TeaSpeak reference has no section connecting features to Chanora's needs. Add "Relevance to Chanora" section mapping TeaSpeak features to Chanora SRS requirements and identifying protocol divergence points.
- **Effort:** M
- **Dependencies:** None
### TODO-080 — Backfill YaTQA reference: complete variable parameters tables
- **Priority:** P2
- **Source:** yatqa-offline-reference review
- **Description:** Variable parameters section severely condensed: ~15 of 50+ client vars, ~15 of 35+ channel vars, ~7 of 70+ server vars. Backfill from `yat.qa/ressourcen/variablen-parameter/`.
- **Effort:** L
- **Dependencies:** None
### TODO-081 — Backfill YaTQA reference: complete anti-flood action table
- **Priority:** P2
- **Source:** yatqa-offline-reference review
- **Description:** Anti-flood section reduced ~80 individual actions to summary tiers. Replace with complete itemized table from `yat.qa/ressourcen/voice-client-anti-flood/`.
- **Effort:** M
- **Dependencies:** None
### TODO-082 — Backfill YaTQA reference: missing error codes and truncated messages
- **Priority:** P2
- **Source:** yatqa-offline-reference review
- **Description:** ~28 error codes missing, several messages truncated (errors 1030, 1035, 522). Add missing codes and fix truncated messages.
- **Effort:** S
- **Dependencies:** None
### TODO-083 — Backfill YaTQA reference: expand ServerQuery notify events
- **Priority:** P2
- **Source:** yatqa-offline-reference review
- **Description:** Missing events: `notifychanneldescriptionchanged`, `notifychannelpasswordchanged`. Most events listed by name only with no field details. Expand with full field lists and behavioral notes.
- **Effort:** M
- **Dependencies:** None
### TODO-084 — Cross-reference YaTQA and ReSpeak error codes
- **Priority:** P3
- **Source:** yatqa-offline-reference review, respeak-protocol-reference review
- **Description:** YaTQA §9 and ReSpeak Errors.csv may have discrepancies. Cross-reference and note differences.
- **Effort:** S
- **Dependencies:** None
---
## 11. Additional Traceability Gaps
### TODO-085 — Add Flutter app documentation
- **Priority:** P2
- **Source:** codebase-analysis §4
- **Description:** Flutter app has <1% documentation coverage (~586 functions, ~5 documented). Add dart doc comments to core services and widgets.
- **Effort:** XL
- **Dependencies:** None
### TODO-086 — Add chanora_bridge doc comments (15% coverage)
- **Priority:** P2
- **Source:** codebase-analysis §4
- **Description:** chanora_bridge has 65 pub fn at 15% documentation. This is the FFI boundary — every function should be documented.
- **Effort:** L
- **Dependencies:** None
### TODO-087 — Track Flutter framework upstream bugs and workarounds
- **Priority:** P2
- **Source:** codebase-analysis §9, issue-history §1
- **Description:** 3+ historical issues required Flutter framework workarounds. Create tracking document for upstream Flutter bugs that affect Chanora and document current workarounds.
- **Effort:** S
- **Dependencies:** None
### TODO-088 — Improve branch management workflow
- **Priority:** P3
- **Source:** codebase-analysis §10, issue-history §2
- **Description:** PR stacking complexity and local CI mirroring noted as development pain points. Evaluate git-worktree or branch management tooling.
- **Effort:** M
- **Dependencies:** None
---
*Generated from codebase analysis on 2026-06-11. 88 items across 11 categories.*
+2 -2
View File
@@ -1,7 +1,7 @@
# Chanora Traceability Matrix
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
**Date:** 2026-06-11 (updated from 2026-05-29; SysDes-to-SRS coverage statement corrected)
**Primary upstream documents:** `docs/sysrs.md`, `docs/sysdes.md`, `docs/srs.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`
## 1. Purpose
@@ -23,7 +23,7 @@ The current repository has SysRS, SysDes, SRS, SAD, and SDD baselines. The SAD a
| Layer | Coverage statement | Source |
|---|---|---|
| SysRS to SysDes | SysDes Appendix A provides allocation for every SysRS requirement | `docs/sysdes.md` Appendix A |
| SysDes to SRS | SRS reports 110 / 110 SysDes design items covered, with 95 / 110 software-impacting items represented | `docs/srs.md` section 9 |
| SysDes to SRS | SRS section 9 covers SysDes-001 through SysDes-110 (110/110); sections 14, 17, and 19 extend coverage through SysDes-158 with SRS-144 through SRS-219 | `docs/srs.md` sections 9, 14, 17, 19 |
| SRS to SAD | SAD section 3 allocates SRS groups to software architecture components | `docs/architecture/sad.md` |
| SAD to SDD | SDD section 11 maps SAD components to detailed design modules | `docs/architecture/sdd.md` |
| SDD to verification | Verification plans map SDD modules and SRS acceptance areas to SWE.4/SWE.5/SWE.6/SYS.4 evidence | `docs/verification/` |
+44 -30
View File
@@ -1,9 +1,12 @@
# Chanora Implementation Status — 2026-05-28
# Chanora Implementation Status — 2026-06-11
> **Updated from 2026-05-28 snapshot.** Changes since last report: poke notification system (protocol classification, bridge strength, Flutter notification service, settings UI, l10n, persistence), file transfer/avatar download with `chanora_cache` (cacache-backed content-addressed blob store), desktop Silero ONNX VAD, Windows PTT modernization, adaptive 3-panel layout, per-user volume controls, event-driven UI, real-time mic level metering, macOS permissions service, macOS lock-free audio event queue, iOS audio session controller, hard-mute owners service, voice join ordering service, shared app snackbar, chat panel widget, voice status summary widget, audio poisoned-mutex recovery, platform-aware client version, dead code removal, code deduplication, and multiple iOS/macOS/Android audio fixes.
**Workspace version:** `v0.2.0-beta.1`
**Flutter app version/build:** `0.3.0+100`
**CHANGELOG latest:** `v0.3.0`
**Build status:** Host Rust workspace evidence shows all 9 crates compile cleanly. This does not claim Android target success; Android target compile/install/smoke evidence remains blocked locally as noted below.
**Rust crates:** 10 (core + 9 workspace crates; `chanora_cache` added since last report)
**Build status:** Host Rust workspace evidence shows all 10 crates compile cleanly. This does not claim Android target success; Android target compile/install/smoke evidence remains blocked locally as noted below.
---
@@ -14,27 +17,32 @@
| Area | Evidence |
|---|---|
| App shell / startup | `main.dart` (2313 lines), `app_bootstrap.dart`, `RustLib.init()` wired |
| Flutter UI | Full widget set: `connect_widgets`, `snapshot_view`, `chat_views`, `voice_bar`, `voice_compact`, `voice_settings`, `voice_settings_controls`, `client_info_sheet`, `input_dialogs`, `bbcode_text` |
| Flutter UI | Full widget set: `connect_widgets`, `snapshot_view`, `chat_views`, `chat_panel`, `voice_bar`, `voice_compact`, `voice_settings`, `voice_settings_controls`, `voice_status_summary`, `client_info_sheet`, `input_dialogs`, `bbcode_text`, `poke_notification_settings`, `app_snack_bar` |
| Material 3 + design tokens | `chanora_tokens.dart`, `platform_capabilities.dart` |
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
| Protocol adapter | `chanora_protocol``tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; branch `simplify-project-review` has started splitting the previous large `lib.rs` into focused internal Modules (`events.rs`, `network_diagnostics.rs`) while preserving public re-exports |
| State sync reducer unit | `chanora_state``ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and Windows/Linux desktop `VoiceActivity` through the capture VAD path. Mobile, macOS, and unverified-platform `VoiceActivity` remain deferred per DEC-030. |
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0L3); missed-key-up watchdog |
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated; file transfer API surface added |
| Protocol adapter | `chanora_protocol``tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue; poke notification strength classification added |
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; core split into focused internal modules (`events.rs`, `network_diagnostics.rs`); poke strength event propagation; bounded disconnect shutdown |
| State sync reducer unit | `chanora_state``ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. Event-driven delta stream replaces timer-based polling for live UI updates (`ProtocolDelta` enum). |
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, poisoned-mutex recovery, hardened realtime callback paths, and Windows/Linux desktop `VoiceActivity` through Silero ONNX VAD + desktop capture VAD path. macOS lock-free audio event queue and channel-aware render downmix. Apple CoreML Silero VAD scaffolding bundled (product `VoiceActivity` on iOS/macOS remains deferred per DEC-030). Mobile and unverified-platform `VoiceActivity` remain deferred per DEC-030. |
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback (modernized), macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0L3); missed-key-up watchdog; release-touch PTT fix; hard-mute owners service (`hard_mute_owners.dart`) |
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter` (30 Hz real-time mic input), `voice_platform`, `voice_status_summary`, `ptt_capability_badge`, `talk_power_warning` |
| Storage (non-secret) | `chanora_storage``BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
| Blob cache | `chanora_cache` — content-addressed blob cache for avatar/icon files, backed by `cacache` (crash-safe, integrity-verified), `av_<md5>`/`ic_<crc32>` key mapping, size-based eviction, separate from persistent storage |
| File transfer | Avatar/icon download wired through bridge (`api.rs` file transfer surface), `chanora_cache` for blob storage, FRB regenerated for file transfer API |
| Storage (secrets) | `IdentityFileStore` with platform keyring (Linux Secret Service, macOS Keychain, Windows Credential Manager, iOS Keychain); file fallback with 0600 perms |
| Diagnostics | `chanora_diagnostics``Redactor` (IP/host/email/token/path/secret scrubbing), `InMemoryLogSink`, `DiagnosticExport` JSON bundle, `KnownSecretRegistry`, panic hook |
| Server address resolution | `chanora_resolver` — SRV/TSDNS/DNS fallback |
| Server prefetch | `chanora_prefetch` crate + `prefetch_debouncer.dart` — invisible host-field prefetch, TTL cache, generation-safe |
| Android platform | `android_voice_unit.rs`, `android_permissions_service.dart`, `MODE_IN_COMMUNICATION` routing, foreground service (`flutter_foreground_task`) |
| iOS platform | `ios_voice_unit.rs`, `ios_raw_unit.rs`, `ios_permissions_service.dart`, `AVAudioSession` integration, `audio_session` package |
| Poke notifications | Protocol-level poke strength classification, bridge `poke_strength` exposure to Flutter, `poke_notification_service.dart`, `poke_preferences_service.dart`, `poke_notification_settings.dart` (l10n en + zh), local notification plugin integration (Android + Apple), empty poke message support |
| Android platform | `android_voice_unit.rs`, `android_permissions_service.dart`, `MODE_IN_COMMUNICATION` routing, foreground service (`flutter_foreground_task`), per-ABI APK splitting |
| iOS platform | `ios_voice_unit.rs`, `ios_raw_unit.rs`, `ios_permissions_service.dart`, `ios_audio_session_controller.dart`, `AVAudioSession` integration, `audio_session` package, WebRTC VAD on iOS capture path |
| macOS platform | `macos_permissions_service.dart` (Input Monitoring, Local Network, Notifications), lock-free audio event queue, channel-aware render downmix, audio lifecycle MethodChannel, Local Network permission denial detection |
| Permission UX | `permission_state_banner.dart`, pre-request explainers |
| Adaptive layout | 3-panel adaptive layout with compact/medium/expanded breakpoints, chat panel switching, gesture-isolated PTT row on mobile |
| Bookmark UI | Save/connect/delete in `connect_widgets.dart` |
| Channel join | Tap-to-join with optional password, `channel_join_error_mapper.dart`, `channel_spacer.dart` |
| Chat | `chat_views.dart`, BBCode rendering (`bbcode_text.dart`) |
| Chat | `chat_views.dart`, `chat_panel.dart`, BBCode rendering (`bbcode_text.dart`), per-user volume controls (`#17`), event-driven client info parity |
| Audio settings UI | `voice_settings.dart`, `voice_settings_controls.dart`, `audio_processing_config_state.dart`, `audio_device_list_tile.dart`, `audio_output_tile.dart` |
| Audio debug stats | `audio_debug_stats_panel.dart` |
| TS3 server link | `ts3_server_link.dart``ts3server://` URI parsing |
@@ -46,15 +54,16 @@
| Link trust | `link_trust_service.dart` |
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
| CI | GitHub Actions on every push |
| Workspace compiles | Host Rust workspace evidence shows all 9 crates build cleanly; Android target compilation remains blocked locally as noted below. |
| Workspace compiles | Host Rust workspace evidence shows all 10 crates build cleanly; Android target compilation remains blocked locally as noted below. |
| Shared UI utilities | `app_snack_bar.dart` — consistent error/info feedback styling |
### Partial / Scaffold Only
| Area | Gap |
|---|---|
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
| Mobile/macOS VoiceActivity | `assets/models/silero_vad.onnx` is bundled and used by the desktop VAD path where runtime evidence supports it; mobile, macOS, and unverified-platform `TransmitMode::VoiceActivity` remain disabled/deferred until a later baseline supplies backend enablement and verification evidence. |
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. Live event-driven deltas (client join/leave/move/update, channel add/remove/update) now flow through `ProtocolDelta` enum for instant UI updates, but this is a separate path from the full reducer integration. |
| Mobile/macOS VoiceActivity | `assets/models/silero_vad.onnx` is bundled and used by the desktop VAD path where runtime evidence supports it; Apple CoreML Silero VAD scaffolding is bundled for iOS/macOS when the private `silero-coreml` SwiftPM package is available; mobile, macOS, and unverified-platform product `TransmitMode::VoiceActivity` remain disabled/deferred until a later baseline supplies backend enablement and verification evidence per DEC-030. |
| macOS build | Source-buildable only; no public release artifact is approved. |
| Windows build | Source-buildable only; no public release artifact is approved. |
| iOS build | Source-buildable/unsigned validation only; no TestFlight/App Store release artifact is approved. |
@@ -66,7 +75,7 @@
| DEC-012 legal/trademark/OSS review | Explicitly open. Public release is blocked. |
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
| Android target compile/install/smoke evidence | Blocked locally until the Android NDK compiler `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized device or emulator. |
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). Release readiness still requires device audio validation and candidate evidence attachment. |
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). iOS audio session controller (`ios_audio_session_controller.dart`) manages lifecycle. Release readiness still requires device audio validation and candidate evidence attachment. |
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
---
@@ -84,25 +93,27 @@
| Opus codec benchmarks | `benches/opus_codec.rs`, `benches/resampler.rs`, `benches/realtime_capture.rs` |
| Audio processing backend abstraction | `processor/mod.rs` with `sonora`, `webrtc_apm`, `noop` backends |
| Per-user mute | SRS-074 implemented in audio gate |
| Per-user volume | Per-user volume controls UI wired (`#17`), persistence and full mixer integration remain follow-up work |
| Protocol adapter isolation | SRS-053 trait boundary in place |
| Adaptive layout | 3-panel adaptive layout with compact/medium/expanded breakpoints (SRS-151 implemented), chat panel switching |
### Not Done (P1 backlog)
| Item | Notes |
|---|---|
| Per-user volume (SRS-075) | Not yet wired to UI/storage |
| Per-user volume (SRS-075) | UI wired (`#17`); persistence and full mixer integration remain follow-up work |
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
| Network diagnostics (SRS-100) | Core tracks connect/disconnect counts and last-loss reasons in `network_diagnostics.rs`; export/integration evidence still needs release-candidate attachment |
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
| Side navigation rail for medium layout (SRS-153) | Adaptive layout supports compact/medium/expanded breakpoints; dedicated side navigation rail not confirmed |
| Keyboard focus traversal (SRS-160) | Not confirmed |
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
| Windows installer packaging (SRS-116) | Not in rc.1 artifacts |
| Linux packaging (AppImage/Flatpak/deb/rpm) (SRS-118) | Not confirmed |
| Android AAB release build pipeline (SDD-109) | Referenced but not confirmed as CI-automated |
| iOS TestFlight/App Store build pipeline (SRS-120) | Deferred |
| Light/dark theme toggle (SAD-043) | Not confirmed in UI |
| Light/dark theme toggle (SAD-043) | Theme mode persisted (`system` / `light` / `dark` via `shared_preferences`); dedicated toggle UI confirmation needed |
| Audio device hot-plug recovery (SRS-082) | Noted as follow-up work in `chanora_audio/src/lib.rs` |
---
@@ -137,26 +148,29 @@
### Needs Attention
- The agent spec docs (`P0_MVP_AGENT_SPEC.md`, `P1_BETA_AGENT_SPEC.md`, etc.) are deleted from the working tree but still in git HEAD. The new `docs/srs.md`, `docs/sysdes.md`, `docs/sysrs.md` are untracked. Docs reorganization in progress — files need to be committed or deletions reverted.
- Docs reorganization is largely complete. `docs/srs.md`, `docs/sysdes.md`, `docs/sysrs.md` are in the working tree. The agent spec docs have been superseded by the current document hierarchy.
---
## Summary
```
P0 / MVP: ~85% done. Core product works end-to-end (connect, voice, chat,
bookmarks, storage, diagnostics). Main blockers: DEC-012 legal
review (hard gate), Android Keystore DEK, iOS voiceChat evidence,
and candidate evidence attachment.
P0 / MVP: ~90% done. Core product works end-to-end (connect, voice, chat,
bookmarks, storage, diagnostics, poke notifications, file transfer/
avatar download, event-driven UI, adaptive layout, per-user volume
UI). Main blockers: DEC-012 legal review (hard gate), Android
Keystore DEK, iOS voiceChat evidence, and candidate evidence
attachment.
P1 / Beta: ~40% done. Audio processing backend, diagnostics export, and
loopback tests are in. Per-user volume, event replay, network
diagnostics, packaging pipelines, and several UI hardening items
remain.
P1 / Beta: ~50% done. Audio processing backend, diagnostics export,
loopback tests, per-user volume UI, adaptive layout are in.
Per-user volume persistence/mixer wiring, event replay, network
diagnostics export, packaging pipelines, and several UI hardening
items remain.
P2 / Prod: ~5% done. Blocked on P0 legal gate. Documentation artifacts
(C4 views, ADRs, glossary) and production signing pipelines
not started.
SOP: Docs in place but a working-tree reorganization is uncommitted.
SOP: Docs reorganized and in working tree. Traceability chain intact.
```
@@ -0,0 +1,136 @@
# Branch Management Workflow
**Document status:** Active reference
**Date:** 2026-06-11
## Branch Naming Convention
Branches use `<type>/<short-description>`:
| Prefix | Purpose | Examples |
|--------|---------|---------|
| `feat/` | New feature | `feat/file-transfer`, `feat/poke-notifications` |
| `fix/` | Bug fix | `fix/ios-audio-playback-capture`, `fix/cross-platform-audio-route` |
| `docs/` | Documentation only | `docs/codebase-analysis-v2`, `docs/export-compliance-closed-source` |
| `refactor/` | Code restructuring | `refactor/remove-ios-raw-unit` |
| `chore/` | Tooling, CI, maintenance | `chore/gitignore-macos-framework-binary`, `chore/upgrade-android-toolchain` |
| `review-pr-<N>` | Review-specific branch | `review-pr-21`, `review-pr-22` |
| `pr-<N>` | PR checkout | `pr-34`, `pr-35` |
| `product/` | Long-lived product milestone | `product/scaffold-v0`, `product/desktop-ui-ux-redesign` |
Scopes in commit messages should match the branch type. Both follow the Conventional Commits convention defined in `docs/governance/git-commit-message-convention.md`.
## Merging Strategy
All PRs merge into the default branch (`main` via GitHub merge commits). Squash merges are not used — branch topology is preserved via `git log --graph`.
## Git Worktree for Parallel Development
This project already uses git worktrees for PR review. Three are currently active:
```
/chanora → docs/codebase-analysis-v2
/tmp/opencode/chanora-pr34-review → simplify-project-review
/tmp/opencode/chanora-pr35-review → (detached HEAD)
/tmp/opencode/chanora-pr36-review → pr-36-review
```
### Why Worktrees
- Review PRs without stashing or losing your working tree state
- Run long CI builds in one tree while editing in another
- Keep multiple feature branches checked out simultaneously
- Avoid `git stash` / `git checkout` churn
### Common Workflows
**Feature branch:**
```bash
git worktree add ../chanora-feat-x feat/my-feature
cd ../chanora-feat-x
# work, commit, push, open PR
```
**Hotfix (from main):**
```bash
git worktree add ../chanora-hotfix -b fix/urgent-bug origin/main
cd ../chanora-hotfix
# fix, commit, push, open PR
# merge PR, then: git worktree remove ../chanora-hotfix
```
**Review branch (checkout a PR):**
```bash
gh pr checkout 36 --detach
# or via worktree:
git worktree add ../chanora-review-36 pr-36-review
```
### Cleanup
```bash
git worktree remove ../chanora-feat-x # after merge
git worktree prune # clean stale entries
git branch -d feat/my-feature # delete merged branch
```
## PR Stacking Pattern
This project uses stacked PRs where features are built on top of each other:
```
main ← #39 (refactor/remove-ios-raw-unit)
└─ #38 (fix/ios-audio-session)
└─ #37 (feat/desktop-silero-vad)
```
To create a stacked PR:
1. Branch from the base PR's branch (not `main`)
2. Push and open PR targeting the base PR's branch
3. After base merges, rebase onto `main` and update the target
For managing stacks, `gh` CLI is sufficient:
```bash
# Create dependent branch
git checkout -b feat/next-thing origin/feat/base-thing
# After base merges:
git rebase origin/main && git push --force-with-lease
# Update PR target via GitHub UI or:
gh pr edit <number> --base main
```
## Local CI Mirroring
The project CI (defined in `.github/workflows/`) runs platform builds (Android, Windows, macOS, iOS), clippy, and tests. To mirror locally before pushing:
```bash
# Rust checks (fast, run always)
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
# Flutter checks
cd flutter && flutter analyze
# Platform builds (run before PR)
cargo build --target aarch64-linux-android
cargo build --target x86_64-pc-windows-msvc
cargo build --target aarch64-apple-darwin
```
Tip: Use `act` (https://github.com/nektos/act) to run GitHub Actions locally for full CI parity:
```bash
act pull_request # runs the PR workflow locally via Docker
```
## Quick Reference
| Task | Command |
|------|---------|
| New feature | `git worktree add ../chanora-X -b feat/X origin/main` |
| Review PR | `gh pr checkout N` or `git worktree add ../review-N pr-N-review` |
| Cleanup after merge | `git worktree remove ../chanora-X && git branch -d feat/X` |
| Check worktrees | `git worktree list` |
| Local CI | `cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace` |
+102
View File
@@ -0,0 +1,102 @@
# tsclientlib Encoding Handling Verification
**Date:** 2026-06-11
**Scope:** Verification of encoding handling in Chanora's tsclientlib fork
**Checkout analyzed:** `~/.cargo/git/checkouts/tsclientlib-b9d0d0212095a0c5/3fbfa26/`
---
## 1. How tsclientlib Handles Encoding
### Command Layer (`tsproto-packets/src/commands.rs`)
The `CommandParser` operates on raw `&[u8]` bytes. String conversion happens at the value level:
- **`CommandArgumentValue::get_str()`** (line 157): Returns `Result<Cow<'a, str>>` using `str::from_utf8()` on raw bytes or `String::from_utf8()` on unescaped bytes.
- **`CommandArgumentValue::get_parse()`** (line 166): Calls `get_str()` then `.parse()` for typed values.
- Both methods return `Utf8Error` on invalid UTF-8 — **strict validation, not lossy**.
### Packet Layer (`tsproto/src/`)
| File | Line | Usage | Behavior |
|------|------|-------|----------|
| `license.rs` | 482, 550 | `str::from_utf8(&data[...])` | Strict — returns `Error::DeserializeString` on failure |
| `log.rs` | 84, 101 | `if let Ok(s) = str::from_utf8(packet.content())` | Silent skip — invalid UTF-8 packets are not logged |
| `client.rs` | 363 | `str::from_utf8(name)` | Used in error message formatting only |
| `algorithms.rs` | 450 | `String::from_utf8_lossy(&dec)` | Lossy — only in debug `println!` |
### Bookkeeping Layer (`utils/ts-bookkeeping/`)
- All string fields in `Connection`, `Server`, `Client`, `Channel` structs are `String` (Rust's UTF-8 type).
- Generated message parsers (via `MessageDeclarations.tt` line 47) use `str::from_utf8(s)?.to_string()` — strict UTF-8 validation.
- `ParseError::StringParse` wraps `std::str::Utf8Error` (messages.rs:26).
---
## 2. UCS-2 and CESU-8 Handling
**No UCS-2 or CESU-8 code exists anywhere in the fork.**
Search for `ucs`, `UCS`, `cesu`, `CESU`, `utf16`, `UTF16`, `utf-16`, `UTF-16` across all `.rs` files returned zero results.
The fork treats all protocol data as UTF-8 bytes. There is no:
- UCS-2 to UTF-8 conversion
- CESU-8 decoding
- UTF-16 handling
- Encoding detection or negotiation
---
## 3. What Happens with Non-UTF-8 Data
If the server sends non-UTF-8 bytes (e.g., UCS-2 encoded emoji from an older client):
| Layer | Behavior |
|-------|----------|
| `CommandParser::get_str()` | Returns `Err(Utf8Error)` — call site decides |
| License parsing | Returns `Err(Error::DeserializeString)` — connection fails |
| Log parsing | `if let Ok(s)` — silently skipped, packet not logged |
| Message parsing | Returns `Err(ParseError::StringParse)` — command rejected |
**Critical:** There is no graceful fallback. Non-UTF-8 data causes errors at the protocol layer.
---
## 4. What the TS3 Protocol Actually Sends
Per ReSpeak protocol reference §1.8.3: "Command string encoded in UTF-8"
The TS3 protocol itself uses UTF-8 for command strings. The UCS-2 concern applies to:
- **Older mobile clients** (pre-3.2.0) that may send CESU-8 encoded supplementary characters
- **Server-side storage** where some servers may store UCS-2 for certain fields
- **Emoji support** added in Server 3.2.0+ (UTF-8 with full Unicode)
In practice, the protocol wire format is UTF-8. The UCS-2 issue is a server/client implementation detail, not a protocol encoding issue.
---
## 5. Risks for Chanora
### Low Risk
- **Normal operation:** Modern TS3 servers and clients send valid UTF-8. No issues expected.
- **Chanora's own strings:** Always valid UTF-8 (Rust `String` type guarantees this).
### Medium Risk
- **Legacy clients:** Older mobile clients sending CESU-8 will cause `Utf8Error` in command parsing. Commands will be rejected, not silently corrupted.
- **Server names/channel names with emoji:** If a server stores names in UCS-2 format (some older databases), Chanora will fail to parse them.
### Mitigation
- The fork uses **strict** UTF-8 validation (not lossy). This is correct behavior — rejecting malformed data is safer than silently corrupting it.
- If UCS-2 compatibility is needed, the fix belongs in the `EdisonJwa/tsclientlib` fork, not in Chanora's protocol layer.
---
## 6. Conclusion
tsclientlib handles encoding correctly for the TS3 protocol:
- All string parsing uses `str::from_utf8()` — strict UTF-8 validation
- No UCS-2/CESU-8 code exists — the fork assumes UTF-8 wire format
- Invalid UTF-8 causes errors, not silent corruption
- The protocol itself is UTF-8; UCS-2 is an edge case from legacy implementations
**Status:** VERIFIED — encoding handling is correct as-is.
@@ -0,0 +1,268 @@
# External Research: TeaSpeak, ReSpeak, YaTQA
**Date:** 2026-06-11
**Purpose:** Competitive analysis, dependency analysis, protocol documentation research
---
## 1. TeaSpeak
### Overview
| Attribute | Details |
|---|---|
| **Name** | TeaSpeak |
| **Website** | teaspeak.de (currently down, archived) |
| **Repository** | github.com/TeaSpeak/TeaSpeak (issue tracker only) |
| **License** | Mixed: Web Client (MPL-2.0), Server (Proprietary), TeaMusic (Open Source C++) |
| **Status** | **Effectively unmaintained** — TeaWeb archived July 2025, last server release ~2022 |
| **Stars** | 120 (main repo), 49 (TeaWeb), 7 (TeaMusic) |
| **Languages** | TypeScript (84%), SCSS, HTML, WebAssembly (web client); C++ (music bot) |
### Architecture
```
TeaSpeak Server (closed-source binary, Linux x64 only)
├── TeaSpeak Web Client (TypeScript, open source, MPL-2.0)
├── TeaSpeak Native Client (closed-source binary, Win/Linux x64)
└── TeaMusic (C++ music bot, open source)
```
### Feature Comparison
| Feature | TeaSpeak | Chanora |
|---|---|---|
| Voice (Opus) | ✅ | ✅ |
| Text Chat | ✅ (markdown) | ✅ |
| Video/Screen Sharing | ✅ (buggy, VP8) | Not planned |
| Music Bot | ✅ (built-in) | Not in scope |
| Channels | ✅ | ✅ |
| Permissions | ✅ (advanced) | Partial |
| File Transfer | ✅ | ✅ (v0.3.0) |
| Web Client | ✅ | Not in scope |
| Native Client | ✅ (closed source) | ✅ (Flutter + Rust) |
| Cross-platform | Partial (Win/Linux) | ✅ (5 platforms) |
| Push-to-Talk | Unknown | ✅ |
| i18n | ✅ (8 languages) | ✅ |
### Lessons for Chanora
**What to learn:**
- Web client as installation-less option is compelling
- Built-in music bot is popular feature
- Hidden/private channels valued by users
- Markdown in chat is well-received
- Multi-language support is expected
**What to avoid:**
- Closed-source server prevents community maintenance
- Monolithic architecture limits extensibility
- Poor maintenance (abandoned since ~2022)
- Buggy video/screen sharing (multiple open issues)
- No ARM support (frequently requested)
- Mixed tech stack (React + jQuery) creates maintenance burden
### Market Opportunity
TeaSpeak's abandonment creates a clear opportunity for Chanora:
- Users seeking open-source TeamSpeak-compatible clients
- TeaSpeak's feature set validates market need
- Chanora's formal engineering process prevents abandonment
---
## 2. ReSpeak Organization
### Overview
| Attribute | Details |
|---|---|
| **URL** | https://github.com/ReSpeak |
| **Mission** | Reverse-engineering and reimplementing TS3 protocol in Rust |
| **Activity** | 16 public repositories, 11 contributors |
| **Language focus** | Rust (primary), C, C#, Python, Svelte/TypeScript |
| **License** | Apache-2.0 / MIT dual-license |
### Repository Inventory
| Repository | Purpose | Stars | Status |
|---|---|---|---|
| **tsclientlib** | Core TS3 protocol library | 140 | Active |
| **tsdeclarations** | Machine-readable protocol declarations | 94 | Active |
| **TS3Hook** | DLL injection for packet decryption | 70 | Archived |
| **rust-ts3plugin** | Rust bindings for TS3 plugin API | 14 | Active |
| **SimpleBot** | Chat bot with custom reactions | 14 | Maintenance |
| **quicklz** | QuickLZ compression for TS3 protocol | 7 | Stable |
| **tomcrypt-rs** | Rust bindings for libtomcrypt | 6 | Dormant |
| **ts3stats** | User statistics from server logs | 6 | Dormant |
| **t4rust** | T4-like template engine for Rust | 5 | Active |
| **Qint** | Full cross-platform TS3 client (Tauri) | 5 | Active |
| **ts3tts** | Text-to-speech plugin | 3 | Maintenance |
| **TsPressor** | Message compressor | 2 | Dormant |
| **rust-ts3plugin-sys** | FFI bindings for TS3 plugin API | 0 | Active |
| **MahTsIdentity** | Identity management | 0 | Dormant |
| **pyTSon** | Python plugin interface | 0 | Dormant |
| **TsVersionChecker** | Client version verification | 1 | Dormant |
### Key Library: tsclientlib
**Architecture (monorepo):**
| Crate | Purpose |
|---|---|
| `tsclientlib` | High-level client API |
| `tsproto` | Low-level protocol (UDP, encryption, fragmentation) |
| `ts-bookkeeping` | State tracking |
| `tsproto-packets` | Packet parsing |
| `tsproto-structs` | Auto-generated structs |
| `tsproto-types` | Basic types |
**Key capabilities:**
- Full TS3 protocol handshake (Init1 RSA puzzle, ECDH key exchange)
- Encryption: EAX mode (AES-128-CTR + OMAC)
- Compression: QuickLZ level 1
- Packet fragmentation and reassembly
- Voice/Opus audio handling
**Performance:**
- ~199ms connection time (RSA puzzle dominant)
- ~189μs per message send
### Chanora's Dependency on ReSpeak
| Dependency | How Chanora Uses It |
|---|---|
| `tsclientlib` | Protocol adapter (`chanora_protocol`) |
| `tsproto` | Network layer, encryption |
| `tsproto-types` | Basic TS3 types |
| `tsproto-packets` | Packet parsing |
| `tsproto-structs` | Command/event structs |
| `ts-bookkeeping` | Server state tracking |
| `AudioHandler` | Voice decode, jitter buffer |
**Note:** Chanora uses a **fork** (`EdisonJwa/tsclientlib`) for a p256 coordinate padding fix.
### Protocol Documentation
`tsdeclarations/ts3protocol.md` is the most comprehensive open TS3 protocol specification:
1. **Low-level packets:** 9 packet types (Voice, VoiceWhisper, Command, CommandLow, Ping, Pong, Ack, AckLow, Init1)
2. **Encryption:** EAX mode (AES-128-CTR + OMAC)
3. **Compression:** QuickLZ level 1
4. **Handshake:** 5-step Init1 (RSA puzzle), then ECDH key exchange
5. **Voice:** Opus codec at 48kHz, whisper targeting modes
6. **Identity:** EC key pairs (prime256v1), hashcash proof-of-work
### Gaps in ReSpeak
| Gap | Impact on Chanora |
|---|---|
| Server code | Not needed (client only) |
| TS5 protocol | No full TS5 client protocol |
| File transfer | No implementation |
| Auto-reconnect | "Not yet there" in README |
| IPv6 support | Not documented |
| Documentation | Sparse code comments |
### Recommendations
**Continue using:**
- `tsclientlib` as primary protocol dependency
- `tsdeclarations` for protocol understanding
**Consider contributing:**
- p256 coordinate padding fix upstream
- Auto-reconnect logic if implemented
**Build internally:**
- File transfer (no existing implementation)
- Auto-reconnect logic
- TS5 compatibility (monitor `tsdeclarations`)
---
## 3. YaTQA (yat.qa)
### Overview
| Attribute | Details |
|---|---|
| **Name** | YaTQA — Yet Another TeamSpeak³ Query App |
| **URL** | https://yat.qa/ |
| **Author** | Janni "Яedeemer" K. from northern Germany |
| **First release** | June 29, 2011 |
| **Latest version** | v3.9.9b (March 1, 2023) |
| **Language** | Delphi 2009 (~50,000+ lines) |
| **Purpose** | GUI alternative to raw ServerQuery telnet commands |
**Note:** "qa" stands for **Query App**, not "Quality Assurance."
### What It Is
YaTQA is a Windows GUI tool for managing TeamSpeak 3 servers via the ServerQuery interface. It is NOT a testing framework.
### Useful Resources
The `/ressourcen/` section contains valuable unofficial documentation:
| Resource | Value for Chanora |
|---|---|
| Server error codes | Comprehensive error handling reference |
| Permission IDs | Permission feature implementation |
| Client versions | Protocol compatibility reference |
| DNS resolver behavior | Server resolution reference |
| Anti-flood mechanics | Rate limiting design |
| Codec configuration | Audio codec handling reference |
| Snapshot format | Server migration features |
| Voice client anti-flood | Rate limiting implementation |
| Other protocols | File transfer, TSDNS, blacklist, weblist, badges |
### Relevance to Chanora
**Low direct relevance** — management tool, not testing framework.
**What to extract:**
1. Unofficial ServerQuery documentation (mostly German)
2. Server error codes for comprehensive error handling
3. Permission IDs for permission features
4. Anti-flood mechanics for rate limiting design
### Codec Reference
YaTQA documents TeamSpeak's codec configuration:
- Six codecs: Speex 8kHz, Speex 16kHz, Speex 32kHz, CELT 48kHz, Opus Voice, Opus Music
- 11 quality levels per codec (010)
- Latency settings for Speex/CELT (2060ms)
- Opus is VBR (variable bitrate)
---
## 4. Summary: What Chanora Can Learn
### From TeaSpeak
- ✅ Open-source client is the right approach
- ✅ Cross-platform support is expected
- ✅ i18n is important
- ❌ Avoid closed-source components
- ❌ Avoid monolithic architecture
- ❌ Avoid mixed tech stacks
### From ReSpeak
- ✅ `tsclientlib` is the right protocol foundation
- ✅ Protocol declarations are valuable reference
- ⚠️ Keep fork in sync with upstream
- ⚠️ Monitor for TS5 protocol updates
- 🔨 Need to build file transfer internally
- 🔨 Need to build auto-reconnect internally
### From YaTQA
- 📚 Unofficial protocol docs are valuable reference
- 📚 Server error codes for error handling
- 📚 Permission IDs for permission features
- 📚 Anti-flood mechanics for rate limiting
- ❌ Not a testing framework — don't try to use it as one
---
*Generated by external research agents on 2026-06-11*
+79
View File
@@ -0,0 +1,79 @@
# Flutter Framework Upstream Bugs and Workarounds
Tracking document for Flutter framework issues that require Chanora-specific workarounds.
---
## 1. Flutter AdaptiveScaffold Discontinued
**Flutter issue:** [flutter/flutter#162965](https://github.com/flutter/flutter/issues/162965)
**Impact:** Chanora's adaptive 3-panel layout cannot use the built-in `AdaptiveScaffold` widget because it has been discontinued by the Flutter team. A custom breakpoint-based layout system was required.
**Workaround:** Chanora implements its own centralized breakpoint system with three layout classes (compact, medium, expanded) using a custom `LayoutBuilder` approach. The breakpoint constants and layout policy are documented in [docs/ui-ux/adaptive-layout-platform-guide.md](../ui-ux/adaptive-layout-platform-guide.md).
**Relevant code:**
- Design spec: `docs/superpowers/specs/2026-06-05-adaptive-3-panel-layout-design.md`
- Breakpoint thresholds follow Material 3 canonical breakpoints (600dp compact, 840dp medium, ≥1024dp expanded).
**Status:** Workaround in place. No Flutter fix expected; this is a permanent design divergence.
---
## 2. Flutter Lacks Native Audio Session Management
**Flutter issue:** No unified Flutter issue; this is a fundamental platform abstraction gap. Flutter's `audioplayers` and similar packages do not expose per-platform audio session lifecycle (iOS `AVAudioSession`, Android `AudioFocus`, macOS Core Audio).
**Impact:** Chanora must manage audio focus, interruption handling, and route changes natively on each platform and bridge them to Dart via `MethodChannel`.
**Workaround:** Chanora implements platform-specific audio lifecycle management:
| Platform | Native Implementation | Dart Bridge |
|----------|-----------------------|-------------|
| iOS | `AppDelegate.swift` — AVAudioSession category/route/interruption observers | `chanora/ios_audio_lifecycle` MethodChannel |
| Android | `AndroidAudioFocusController.kt` — AudioFocus request/abandon with `OnAudioFocusChangeListener` | `chanora/android_audio_lifecycle` MethodChannel |
| macOS | `MacOSAudioLifecycle` — Core Audio HAL default-device and stream-format change observers | `chanora/macos_audio_lifecycle` MethodChannel |
**Relevant code:**
- Dart dispatcher: `apps/chanora_flutter/lib/services/audio_lifecycle_service.dart`
- iOS native: `apps/chanora_flutter/ios/Runner/AppDelegate.swift`
- Android native: `apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioFocusController.kt`
- macOS native: `apps/chanora_flutter/macos/`
**Status:** Ongoing. Each platform's MethodChannel handler catches and swallows errors from the Rust side to prevent exception propagation back through the platform framework (a Flutter restriction on `MethodChannel` handlers).
---
## 3. macOS Default-Device Change Not Exposed to Flutter
**Flutter issue:** No Flutter issue filed. Flutter's desktop audio support does not expose Core Audio HAL default-input/output device change notifications.
**Impact:** When the user changes the default audio device on macOS (e.g., plugging in headphones), the Chanora audio engine does not automatically rebind to the new device.
**Workaround:** The macOS `MethodChannel` (`chanora/macos_audio_lifecycle`) receives `handleDefaultDeviceChange` and `handleConfigurationChange` events from native Swift code, but the corresponding Rust-side FRB bridge function (`macosDefaultDeviceChanged`) is not yet exposed via `flutter_rust_bridge`. Events are captured and logged for observability only.
**Relevant code:**
- `apps/chanora_flutter/lib/services/audio_lifecycle_service.dart:150-157` — macOS handler with TODO markers
- `apps/chanora_flutter/lib/services/audio_lifecycle_service.dart:151``// TODO: call rust.macosDefaultDeviceChanged() once exposed`
**Status:** Partial workaround. Requires `flutter_rust_bridge` update to expose the Rust function.
---
## 4. iOS Audio Session Activation Ordering
**Flutter issue:** No Flutter issue filed. Flutter does not provide a mechanism to coordinate audio session activation with network connection establishment.
**Impact:** On iOS, the `AVAudioSession` must be activated before the voice channel join RPC, and deactivated on failure. Without careful ordering, audio may be left in an active state after a failed join, or the session may not be active when the audio engine starts.
**Workaround:** Chanora implements an explicit ordering function `joinVoiceChannelWithIosAudioSession` that activates the iOS audio session, attempts the voice join, and deactivates on failure. A `VoiceJoinSuccessPredicate` allows callers to treat certain errors (e.g., "already in channel") as successful joins, keeping the audio session active.
**Relevant code:**
- `apps/chanora_flutter/lib/services/voice_join_ordering.dart` — Full implementation
- The `activateIosAudioSession`/`deactivateIosAudioSession` callbacks are provided by the iOS native side via `AppDelegate.swift`.
**Status:** Workaround in place. This is an inherent platform design requirement rather than a Flutter bug.
+73
View File
@@ -0,0 +1,73 @@
# Performance Benchmarks
**Date:** 2026-06-11
**Purpose:** Track performance baselines for Chanora and reference benchmarks from upstream projects.
---
## 1. Upstream Reference: tsclientlib (ReSpeak)
**Source:** `docs/references/respeak-protocol-reference.md` §10
**Hardware:** i7-5280K, 6 cores/12 threads @ 3.6 GHz (single thread)
| Metric | Value | Notes |
|---|---|---|
| Connection time | ~199 ms | RSA puzzle solving is dominant bottleneck |
| Message send | ~189 µs | 5,300 messages/sec |
| Connections/sec | ~6.5 | Limited by RSA puzzle |
---
## 2. Chanora Current Benchmarks
**Source:** `current.json` (2026-06-09, `x86_64-unknown-linux-gnu`, rustc 1.95.0)
| Metric | Value | Unit |
|---|---|---|
| Opus encode latency | ~70 µs | 70,216 ns |
| Opus decode latency | ~16 µs | 15,873 ns |
| Resampler 44.1kHz→48kHz | TBD | samples/sec |
| Resampler 16kHz→48kHz | TBD | samples/sec |
| Resampler 48kHz passthrough | TBD | samples/sec |
| Capture alloc count | TBD | blocks |
| Capture callback wall clock | TBD | ns |
---
## 3. Target Benchmarks
| Metric | Target | Rationale |
|---|---|---|
| Opus encode | < 100 µs | Must fit within 10ms frame budget with headroom |
| Opus decode | < 25 µs | Must handle multi-user mixing (N decodes per frame) |
| Connection time | < 500 ms | User-perceived latency for server join |
| Message round-trip | < 500 µs | Command acknowledgement within 1 frame |
| Audio pipeline (capture→encode→send) | < 5 ms | Real-time constraint for 10ms Opus frames |
| Audio pipeline (receive→decode→mix→play) | < 5 ms | Real-time constraint |
| Reconnect time | < 3 s | Including exponential backoff first retry (1s) |
---
## 4. Benchmark Methodology
### Audio Latency
- Measured via `criterion` benchmarks in `chanora_audio`
- Single-threaded, no contention
- Represents encode/decode only (excludes I/O)
### Connection Time
- Measured from `connect()` call to first `Connected` event
- Includes: DNS resolution, TCP, Init1-5 handshake, ECDH, auth
- RSA puzzle (~199ms) dominates
### Message Throughput
- Measured for command send path only (no network I/O)
- Includes serialization + encryption
---
## 5. Notes
- tsclientlib benchmarks are from 2019-era hardware; modern CPUs ~4x faster for RSA
- Chanora's Opus benchmarks are production-quality (well under 10ms frame budget)
- Multi-user mixing performance not yet benchmarked (critical for large channels)
+428
View File
@@ -0,0 +1,428 @@
# TS3 Permission ID Mapping to Chanora
**TODO-069** — Map TS3 permissions to Chanora's permission system.
---
## 1. TS3 Permission System Overview
### 1.1 Permission Tiers (from YaTQA §1.2)
Permissions are evaluated top-to-bottom, lowest tier wins:
| Tier | Level | Description |
|------|-------|-------------|
| 0 | Server Groups | Highest value wins (unless Negate flag) |
| 1 | Client (Server level) | Client-specific server permissions |
| 2 | Channel | Skipped if Skip flag set |
| 3 | Channel Group | Skipped if Skip flag set |
| 4 | Client (Channel level) | Client-specific channel permissions |
### 1.2 Permission Types (from Enums.toml)
| Type | ID1 | ID2 |
|------|-----|-----|
| ServerGroup | ServerGroupId | 0 |
| GlobalClient | ClientDbId | 0 |
| Channel | ChannelId | 0 |
| ChannelGroup | ChannelId | ChannelGroupId |
| ChannelClient | ChannelId | ClientDbId |
### 1.3 Special Flags
- **Skip flag**: Skips channel and channel group permissions
- **Negate flag**: If ANY server group has Negate, only Negate groups count
- **Grant permission**: `i_needed_permission_modify_power_*` — controls who can modify a permission
---
## 2. Chanora's Current Permission Handling
### 2.1 What Exists
| Component | Permission Handling | Location |
|-----------|-------------------|----------|
| `chanora_bridge::permission_jni` | Android `RECORD_AUDIO` runtime permission only | `crates/chanora_bridge/src/permission_jni.rs` |
| `chanora_bridge::api::PermissionStateKind` | Android permission state enum (Granted/Denied/PermanentlyDenied) | `crates/chanora_bridge/src/api.rs` |
| `chanora_core` | No TS3 permission model | — |
| `chanora_protocol` | Error code `0x0a08` (permissions_client_insufficient) recognized but no permission ID mapping | `crates/chanora_protocol/src/lib.rs:109` |
| `chanora_protocol::adapter` | Permission-gated fields (e.g., `client_address` empty when no permission) | `crates/chanora_protocol/src/dto.rs:181` |
| `VoiceJoinErrorCode::JoinDenied` | Generic "denied by server policy/permission" | `core/chanora_core/src/events.rs:267` |
### 2.2 What Does NOT Exist
- No TS3 permission ID constants
- No permission evaluation engine (Skip/Negate/Grant logic)
- No per-permission checks before protocol commands
- No permission-aware UI gating
- No server group / channel group permission tracking
---
## 3. Permission ID Mapping
The following table maps all TS3 permission IDs from `Permissions.csv` / ReSpeak reference §3 to Chanora's current handling.
### 3.1 Server Instance Permissions
| Permission Name | Category | Chanora Status | Notes |
|----------------|----------|----------------|-------|
| `b_serverinstance_help_view` | Query | Not needed | ServerQuery only |
| `b_serverinstance_version_view` | Query | Not needed | ServerQuery only |
| `b_serverinstance_info_view` | Query | Not needed | ServerQuery only |
| `b_serverinstance_virtualserver_list` | Query | Not needed | ServerQuery only |
| `b_serverinstance_binding_list` | Query | Not needed | ServerQuery only |
| `b_serverinstance_permission_list` | Query | Not needed | ServerQuery only |
| `b_serverinstance_permission_find` | Query | Not needed | ServerQuery only |
| `b_virtualserver_create` | Admin | Not needed | Admin operation |
| `b_virtualserver_delete` | Admin | Not needed | Admin operation |
| `b_virtualserver_start_any` | Admin | Not needed | Admin operation |
| `b_virtualserver_stop_any` | Admin | Not needed | Admin operation |
| `b_virtualserver_change_machine_id` | Admin | Not needed | Admin operation |
| `b_virtualserver_change_template` | Admin | Not needed | Admin operation |
| `b_serverquery_login` | Query | Not needed | ServerQuery only |
| `b_serverinstance_textmessage_send` | Query | Not needed | ServerQuery only |
| `b_serverinstance_log_view` | Query | Not needed | ServerQuery only |
| `b_serverinstance_log_add` | Query | Not needed | ServerQuery only |
| `b_serverinstance_stop` | Admin | Not needed | Admin operation |
| `b_serverinstance_modify_settings` | Admin | Not needed | Admin operation |
| `b_serverinstance_modify_querygroup` | Admin | Not needed | Admin operation |
| `b_serverinstance_modify_templates` | Admin | Not needed | Admin operation |
### 3.2 Virtual Server Permissions
| Permission Name | Category | Chanora Status | Priority | Notes |
|----------------|----------|----------------|----------|-------|
| `b_virtualserver_select` | Query | Not needed | — | ServerQuery only |
| `b_virtualserver_info_view` | Read | **Implicit** | P3 | Used by `serverinfo` command |
| `b_virtualserver_connectioninfo_view` | Read | **Implicit** | P3 | Used by `serverrequestconnectioninfo` |
| `b_virtualserver_channel_list` | Read | **Implicit** | P3 | Used by `channellist` command |
| `b_virtualserver_channel_search` | Read | Not needed | — | ServerQuery only |
| `b_virtualserver_client_list` | Read | **Implicit** | P3 | Used by `clientlist` command |
| `b_virtualserver_client_search` | Read | Not needed | — | ServerQuery only |
| `b_virtualserver_client_dblist` | Read | Not needed | — | ServerQuery only |
| `b_virtualserver_client_dbsearch` | Read | Not needed | — | ServerQuery only |
| `b_virtualserver_client_dbinfo` | Read | **Implicit** | P3 | Used by `clientdbinfo` |
| `b_virtualserver_permission_find` | Read | Not needed | — | ServerQuery only |
| `b_virtualserver_custom_search` | Read | Not needed | — | ServerQuery only |
| `b_virtualserver_start` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_stop` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_token_list` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_token_add` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_token_use` | Connect | **Implicit** | P2 | Used in `clientinit` default_token |
| `b_virtualserver_token_delete` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_log_view` | Query | Not needed | — | ServerQuery only |
| `b_virtualserver_log_add` | Query | Not needed | — | ServerQuery only |
| `b_virtualserver_join_ignore_password` | Connect | Not needed | — | Rare |
| `b_virtualserver_notify_register` | Read | **Implicit** | P3 | Used by `servernotifyregister` |
| `b_virtualserver_notify_unregister` | Read | **Implicit** | P3 | Used by `servernotifyunregister` |
| `b_virtualserver_snapshot_create` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_snapshot_deploy` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_permission_reset` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_name` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_welcomemessage` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_maxclients` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_reserved_slots` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_password` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_default_servergroup` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_default_channelgroup` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_default_channeladmingroup` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_channel_forced_silence` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_complain` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_antiflood` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_ft_settings` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_ft_quotas` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_hostmessage` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_hostbanner` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_hostbutton` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_port` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_autostart` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_needed_identity_security_level` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_priority_speaker_dimm_modificator` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_log_settings` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_min_client_version` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_icon_id` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_weblist` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_codec_encryption_mode` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_temporary_passwords` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_temporary_passwords_own` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_channel_temp_delete_delay_default` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_nickname` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_modify_integrations` | Admin | Not needed | — | Admin operation |
### 3.3 Channel Permissions
| Permission Name | Category | Chanora Status | Priority | Notes |
|----------------|----------|----------------|----------|-------|
| `i_channel_min_depth` | Channel | Not needed | — | Server-side only |
| `i_channel_max_depth` | Channel | Not needed | — | Server-side only |
| `b_channel_group_inheritance_end` | Channel | Not needed | — | Server-side only |
| `i_channel_permission_modify_power` | Admin | Not needed | — | Admin operation |
| `i_channel_needed_permission_modify_power` | Admin | Not needed | — | Admin operation |
| `b_channel_info_view` | Read | **Implicit** | P3 | Used by `channelinfo` |
| `b_channel_create_child` | Channel | Not needed | — | Server-side only |
| `b_channel_create_permanent` | Channel | Not needed | — | Server-side only |
| `b_channel_create_semi_permanent` | Channel | Not needed | — | Server-side only |
| `b_channel_create_temporary` | Channel | Not needed | — | Server-side only |
| `b_channel_create_private` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_topic` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_description` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_password` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_codec_speex8` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_codec_speex16` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_codec_speex32` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_codec_celtmono48` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_codec_opusvoice` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_codec_opusmusic` | Channel | Not needed | — | Server-side only |
| `i_channel_create_modify_with_codec_maxquality` | Channel | Not needed | — | Server-side only |
| `i_channel_create_modify_with_codec_latency_factor_min` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_maxclients` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_maxfamilyclients` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_sortorder` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_default` | Channel | Not needed | — | Server-side only |
| `b_channel_create_with_needed_talk_power` | Channel | Not needed | — | Server-side only |
| `b_channel_create_modify_with_force_password` | Channel | Not needed | — | Server-side only |
| `i_channel_create_modify_with_temp_delete_delay` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_parent` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_make_default` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_make_permanent` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_make_semi_permanent` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_make_temporary` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_name` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_topic` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_description` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_password` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_codec` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_codec_quality` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_codec_latency_factor` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_maxclients` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_maxfamilyclients` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_sortorder` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_needed_talk_power` | Channel | Not needed | — | Server-side only |
| `i_channel_modify_power` | Channel | Not needed | — | Server-side only |
| `i_channel_needed_modify_power` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_make_codec_encrypted` | Channel | Not needed | — | Server-side only |
| `b_channel_modify_temp_delete_delay` | Channel | Not needed | — | Server-side only |
| `b_channel_delete_permanent` | Channel | Not needed | — | Server-side only |
| `b_channel_delete_semi_permanent` | Channel | Not needed | — | Server-side only |
| `b_channel_delete_temporary` | Channel | Not needed | — | Server-side only |
| `b_channel_delete_flag_force` | Channel | Not needed | — | Server-side only |
| `i_channel_delete_power` | Channel | Not needed | — | Server-side only |
| `i_channel_needed_delete_power` | Channel | Not needed | — | Server-side only |
| `b_channel_join_permanent` | **Join** | **Implicit** | **P1** | Used by `clientmove` |
| `b_channel_join_semi_permanent` | **Join** | **Implicit** | **P1** | Used by `clientmove` |
| `b_channel_join_temporary` | **Join** | **Implicit** | **P1** | Used by `clientmove` |
| `b_channel_join_ignore_password` | **Join** | Not needed | P3 | Rare |
| `b_channel_join_ignore_maxclients` | **Join** | Not needed | P3 | Rare |
| `i_channel_join_power` | **Join** | **Implicit** | **P1** | Server evaluates on join |
| `i_channel_needed_join_power` | **Join** | **Implicit** | **P1** | Server evaluates on join |
| `i_channel_subscribe_power` | Subscribe | **Implicit** | P2 | Used by `channelsubscribe` |
| `i_channel_needed_subscribe_power` | Subscribe | **Implicit** | P2 | Used by `channelsubscribe` |
| `i_channel_description_view_power` | Read | Not needed | P3 | Low priority |
| `i_channel_needed_description_view_power` | Read | Not needed | P3 | Low priority |
### 3.4 Group Permissions
| Permission Name | Category | Chanora Status | Priority | Notes |
|----------------|----------|----------------|----------|-------|
| `i_icon_id` | Group | Not needed | — | Server-side only |
| `i_max_icon_filesize` | Group | Not needed | — | Server-side only |
| `b_icon_manage` | Group | Not needed | — | Server-side only |
| `b_group_is_permanent` | Group | Not needed | — | Server-side only |
| `i_group_auto_update_type` | Group | Not needed | — | Server-side only |
| `i_group_auto_update_max_value` | Group | Not needed | — | Server-side only |
| `i_group_sort_id` | Group | Not needed | — | Server-side only |
| `i_group_show_name_in_tree` | Group | Not needed | — | Server-side only |
| `b_virtualserver_servergroup_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_servergroup_permission_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_servergroup_client_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_channelgroup_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_channelgroup_permission_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_channelgroup_client_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_client_permission_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_channel_permission_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_channelclient_permission_list` | Group | Not needed | — | ServerQuery only |
| `b_virtualserver_servergroup_create` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_channelgroup_create` | Admin | Not needed | — | Admin operation |
| `i_group_modify_power` | Admin | Not needed | — | Admin operation |
| `i_group_needed_modify_power` | Admin | Not needed | — | Admin operation |
| `i_group_member_add_power` | Admin | Not needed | — | Admin operation |
| `i_group_needed_member_add_power` | Admin | Not needed | — | Admin operation |
| `i_group_member_remove_power` | Admin | Not needed | — | Admin operation |
| `i_group_needed_member_remove_power` | Admin | Not needed | — | Admin operation |
| `i_permission_modify_power` | Admin | Not needed | — | Admin operation |
| `b_permission_modify_power_ignore` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_servergroup_delete` | Admin | Not needed | — | Admin operation |
| `b_virtualserver_channelgroup_delete` | Admin | Not needed | — | Admin operation |
### 3.5 Client Permissions
| Permission Name | Category | Chanora Status | Priority | Notes |
|----------------|----------|----------------|----------|-------|
| `i_client_permission_modify_power` | Admin | Not needed | — | Admin operation |
| `i_client_needed_permission_modify_power` | Admin | Not needed | — | Admin operation |
| `i_client_max_clones_uid` | Connect | Not needed | — | Server-side only |
| `i_client_max_idletime` | Client | Not needed | P3 | Could display idle timeout |
| `i_client_max_avatar_filesize` | Client | Not needed | P3 | Could limit avatar upload |
| `i_client_max_channel_subscriptions` | Client | Not needed | P3 | Could limit subscriptions |
| `b_client_is_priority_speaker` | **Voice** | **Implicit** | **P1** | Tracked in `Client.is_priority_speaker` |
| `b_client_skip_channelgroup_permissions` | Client | Not needed | — | Server-side only |
| `b_client_force_push_to_talk` | **Voice** | Not needed | P2 | Could force PTT mode |
| `b_client_ignore_bans` | Client | Not needed | — | Server-side only |
| `b_client_ignore_antiflood` | Client | Not needed | — | Server-side only |
| `b_client_issue_client_query_command` | Query | Not needed | — | ServerQuery only |
| `b_client_use_reserved_slot` | Connect | Not needed | P3 | Could show slot availability |
| `b_client_use_channel_commander` | **Voice** | Not needed | P2 | Channel commander feature |
| `b_client_request_talker` | **Voice** | **Implicit** | **P1** | `client_talk_request` in events |
| `b_client_avatar_delete_other` | Client | Not needed | — | Server-side only |
| `b_client_is_sticky` | Client | Not needed | P3 | Could show sticky indicator |
| `b_client_ignore_sticky` | Client | Not needed | P3 | Could show sticky indicator |
| `b_client_info_view` | **Read** | **Implicit** | **P1** | Used by `clientinfo` |
| `b_client_permissionoverview_view` | Read | Not needed | — | ServerQuery only |
| `b_client_permissionoverview_own` | Read | Not needed | — | ServerQuery only |
| `b_client_remoteaddress_view` | **Read** | **Implicit** | **P2** | Gated in `BridgeClientProfile.client_address` |
| `i_client_serverquery_view_power` | Read | Not needed | P3 | Could gate query client visibility |
| `i_client_needed_serverquery_view_power` | Read | Not needed | P3 | Could gate query client visibility |
| `b_client_custom_info_view` | Read | Not needed | P3 | Custom fields |
| `i_client_kick_from_server_power` | **Moderation** | Not needed | **P1** | Future: kick UI |
| `i_client_needed_kick_from_server_power` | **Moderation** | Not needed | **P1** | Future: kick UI |
| `i_client_kick_from_channel_power` | **Moderation** | Not needed | **P1** | Future: kick UI |
| `i_client_needed_kick_from_channel_power` | **Moderation** | Not needed | **P1** | Future: kick UI |
| `i_client_ban_power` | **Moderation** | Not needed | **P1** | Future: ban UI |
| `i_client_needed_ban_power` | **Moderation** | Not needed | **P1** | Future: ban UI |
| `i_client_move_power` | **Moderation** | Not needed | **P1** | Future: move UI |
| `i_client_needed_move_power` | **Moderation** | Not needed | **P1** | Future: move UI |
| `i_client_complain_power` | Moderation | Not needed | P2 | Future: complain UI |
| `i_client_needed_complain_power` | Moderation | Not needed | P2 | Future: complain UI |
| `b_client_complain_list` | Moderation | Not needed | P2 | Future: complain UI |
| `b_client_complain_delete_own` | Moderation | Not needed | P2 | Future: complain UI |
| `b_client_complain_delete` | Moderation | Not needed | P2 | Future: complain UI |
| `b_client_ban_list` | Moderation | Not needed | P2 | Future: ban UI |
| `b_client_ban_create` | Moderation | Not needed | P2 | Future: ban UI |
| `b_client_ban_delete_own` | Moderation | Not needed | P2 | Future: ban UI |
| `b_client_ban_delete` | Moderation | Not needed | P2 | Future: ban UI |
| `i_client_ban_max_bantime` | Moderation | Not needed | P2 | Future: ban UI |
| `i_client_private_textmessage_power` | **Chat** | Not needed | **P1** | Could gate DM UI |
| `i_client_needed_private_textmessage_power` | **Chat** | Not needed | **P1** | Could gate DM UI |
| `b_client_server_textmessage_send` | **Chat** | Not needed | **P1** | Could gate server chat |
| `b_client_channel_textmessage_send` | **Chat** | Not needed | **P1** | Could gate channel chat |
| `b_client_offline_textmessage_send` | Chat | Not needed | P2 | Offline messages |
| `i_client_talk_power` | **Voice** | **Implicit** | **P1** | Tracked in `Client.talk_power` |
| `i_client_needed_talk_power` | **Voice** | **Implicit** | **P1** | Compared against channel `needed_talk_power` |
| `i_client_poke_power` | Chat | Not needed | P2 | Could gate poke UI |
| `i_client_needed_poke_power` | Chat | Not needed | P2 | Could gate poke UI |
| `b_client_set_flag_talker` | **Voice** | Not needed | **P1** | Future: grant talk power |
| `i_client_whisper_power` | **Voice** | Not needed | P2 | Whisper feature |
| `i_client_needed_whisper_power` | **Voice** | Not needed | P2 | Whisper feature |
| `b_client_modify_description` | Client | Not needed | P2 | Future: edit description |
| `b_client_modify_own_description` | Client | Not needed | P2 | Future: edit own description |
| `b_client_modify_dbproperties` | Client | Not needed | — | Server-side only |
| `b_client_delete_dbproperties` | Client | Not needed | — | Server-side only |
| `b_client_create_modify_serverquery_login` | Query | Not needed | — | ServerQuery only |
### 3.6 File Transfer Permissions
| Permission Name | Category | Chanora Status | Priority | Notes |
|----------------|----------|----------------|----------|-------|
| `b_ft_ignore_password` | File | Not needed | P3 | File browser |
| `b_ft_transfer_list` | File | Not needed | P3 | File browser |
| `i_ft_file_upload_power` | File | Not needed | P3 | File browser |
| `i_ft_needed_file_upload_power` | File | Not needed | P3 | File browser |
| `i_ft_file_download_power` | File | Not needed | P3 | File browser |
| `i_ft_needed_file_download_power` | File | Not needed | P3 | File browser |
| `i_ft_file_delete_power` | File | Not needed | P3 | File browser |
| `i_ft_needed_file_delete_power` | File | Not needed | P3 | File browser |
| `i_ft_file_rename_power` | File | Not needed | P3 | File browser |
| `i_ft_needed_file_rename_power` | File | Not needed | P3 | File browser |
| `i_ft_file_browse_power` | File | Not needed | P3 | File browser |
| `i_ft_needed_file_browse_power` | File | Not needed | P3 | File browser |
| `i_ft_directory_create_power` | File | Not needed | P3 | File browser |
| `i_ft_needed_directory_create_power` | File | Not needed | P3 | File browser |
| `i_ft_quota_mb_download_per_client` | File | Not needed | P3 | File browser |
| `i_ft_quota_mb_upload_per_client` | File | Not needed | P3 | File browser |
---
## 4. Summary: Permissions Chanora Should Track
### 4.1 Already Handled (Implicit via Protocol)
These permissions are evaluated server-side; Chanora sends commands and handles rejection:
| Permission | How Handled |
|-----------|-------------|
| `b_channel_join_*` | `voice_join` → server rejects with error code |
| `i_channel_join_power` / `i_channel_needed_join_power` | Server evaluates; Chanora gets `JoinDenied` |
| `b_client_info_view` | `clientinfo` command; server returns error if denied |
| `b_client_remoteaddress_view` | `client_address` field gated in `BridgeClientProfile` |
| `i_client_talk_power` / `i_client_needed_talk_power` | Tracked in `Client.talk_power`; server enforces |
| `b_client_is_priority_speaker` | Tracked in client state |
| `b_client_request_talker` | Tracked via `client_talk_request` events |
### 4.2 Missing — Should Add (P1)
| Permission | Reason | Implementation |
|-----------|--------|----------------|
| `i_client_kick_from_server_power` / `i_client_kick_from_channel_power` | Future kick UI needs to know if user can kick | Add to `BridgeClientProfile` or `PermissionHints` |
| `i_client_ban_power` | Future ban UI | Add to permission hints |
| `i_client_move_power` | Future move UI | Add to permission hints |
| `i_client_private_textmessage_power` | Gate DM input in UI | Add to permission hints |
| `b_client_server_textmessage_send` | Gate server chat input | Add to permission hints |
| `b_client_channel_textmessage_send` | Gate channel chat input | Add to permission hints |
| `b_client_set_flag_talker` | Future "grant talk power" button | Add to permission hints |
### 4.3 Missing — Nice to Have (P2-P3)
| Permission | Reason |
|-----------|--------|
| `b_client_force_push_to_talk` | Could force PTT mode in UI |
| `b_client_use_channel_commander` | Channel commander feature |
| `i_client_whisper_power` | Whisper feature |
| `b_client_complain_*` | Complain UI |
| `b_client_ban_*` | Ban management UI |
| `i_ft_*` | File transfer browser permissions |
---
## 5. Recommended Implementation
### 5.1 Permission Constants Module
Generate from `Permissions.csv`:
```rust
// crates/chanora_protocol/src/permissions.rs
// Auto-generated from ReSpeak/tsdeclarations Permissions.csv
pub const B_CHANNEL_JOIN_PERMANENT: &str = "b_channel_join_permanent";
pub const B_CHANNEL_JOIN_SEMI_PERMANENT: &str = "b_channel_join_semi_permanent";
pub const B_CHANNEL_JOIN_TEMPORARY: &str = "b_channel_join_temporary";
pub const I_CHANNEL_JOIN_POWER: &str = "i_channel_join_power";
pub const I_CHANNEL_NEEDED_JOIN_POWER: &str = "i_channel_needed_join_power";
// ... all 253 permissions
```
### 5.2 Permission Hints Integration
Extend the existing `PermissionHints` bitflags (already in `Enums.toml` / ReSpeak §6) to cover the P1 permissions. The server already sends `PermissionHints` in `clientinfo` responses — Chanora just needs to parse and expose them.
### 5.3 UI Gating
Add permission-aware UI state to `BridgeEvent` or `BridgeClientProfile`:
```rust
pub struct BridgePermissionHints {
pub can_kick_server: bool,
pub can_kick_channel: bool,
pub can_ban: bool,
pub can_move: bool,
pub can_send_private_message: bool,
pub can_send_server_message: bool,
pub can_send_channel_message: bool,
pub can_grant_talk_power: bool,
}
```
---
*Created: 2026-06-11 for TODO-069*
@@ -0,0 +1,139 @@
# Protocol Implementation Traps — Developer Notes
**Date:** 2026-06-11
**Purpose:** Compile protocol quirks and gotchas from YaTQA, ReSpeak, and TeaSpeak references that could cause bugs if not handled.
---
## 1. Icon ID Signedness (HIGH)
**What:** Icon IDs are `i32` (signed), not `u32`. Read contexts return signed values; write contexts may expect unsigned.
**Why it's a trap:** Negative icon IDs from permlist/clientinfo will be misinterpreted as large positive numbers if stored as u32.
**How to handle:** Use `i32` for all icon ID fields. When displaying, convert to absolute value or handle negative as "no icon".
**Source:** YaTQA §1.4
## 2. Avatar Flag = MD5 Hash (HIGH)
**What:** `client_flag_avatar` is an MD5 hash string, not a boolean.
**Why it's a trap:** Treating it as a boolean (present/absent) loses the identity reference. Avatar filename is derived from Global ID, not from the flag.
**How to handle:** Store avatar flag as a string. Use Global ID → Base64 → `[a-p]` encoding for filename (YaTQA §1.5).
**Source:** YaTQA §1.5
## 3. Channel Subscription = ONE at a Time (MEDIUM)
**What:** `channelsubscribe` command subscribes to exactly ONE channel. No array parameter exists.
**Why it's a trap:** Attempting to subscribe to multiple channels in one command will fail silently or error.
**How to handle:** Send individual subscribe commands per channel. Track subscription state client-side.
**Source:** YaTQA §3.1
## 4. Line Terminator = 0x0A 0x0D (MEDIUM)
**What:** TS3 protocol line terminator is `0x0A 0x0D` (reversed Windows `\n\r`).
**Why it's a trap:** Standard `\r\n` (0x0D 0x0A) will cause parse failures. Max line length: 9203 bytes (excluding terminator).
**How to handle:** Use `\n\r` (0x0A 0x0D) for all command protocol messages. tsclientlib handles this internally.
**Source:** YaTQA §1.3
## 5. UCS-2 vs UTF-8 Encoding (HIGH)
**What:** TS3 claims UTF-8 but actually uses UCS-2 (BMP only). Mobile apps use CESU-8. Server 3.2.0+ adds partial SIP support (emoji ranges).
**Why it's a trap:** Characters outside BMP (emoji, rare CJK) may not round-trip correctly. tsclientlib uses `str::from_utf8()` — if server sends UCS-2, it will error.
**How to handle:** tsclientlib/tsproto handles encoding at the protocol layer. If encoding bugs are found, fix in the EdisonJwa/tsclientlib fork. Document any encoding-related issues here.
**Source:** YaTQA §1.3, ReSpeak §1.8.3
## 6. IPv6 Canonicalization Bug (LOW)
**What:** TS3 server 3.1.6-3.1.7 has IPv6 canonicalization bug in Blacklist2 protocol.
**Why it's a trap:** Connecting to older servers via IPv6 may trigger false blacklist matches.
**How to handle:** Be aware when connecting to older servers. Not actionable from client side.
**Source:** YaTQA §8.7
## 7. Channel Icon Semi-Permanent (MEDIUM)
**What:** `channel_icon_id` changes via `channeledit` are semi-permanent (lost on server restart).
**Why it's a trap:** Icon changes appear to succeed but revert after restart. Users may think their changes are persistent.
**How to handle:** Document this limitation in UI. Don't rely on channel icon persistence for critical features.
**Source:** YaTQA §4.2
## 8. Avatar Filename from Global ID (MEDIUM)
**What:** Avatar filename is derived from Global ID (not from avatar flag). Algorithm: Base64-decode Global ID → 20 bytes → display with `[a-p]` instead of `[0-9a-f]`.
**Why it's a trap:** Using the avatar flag directly as a filename will fail to find the cached avatar.
**How to handle:** Implement the Global ID → filename conversion. Store avatars in `cache/clients/` directory.
**Source:** YaTQA §1.5, §1.7
## 9. Snapshot SHA1 Hash Format (LOW)
**What:** Snapshot file starts with SHA1 hash of remaining UTF-8 data (excluding trailing newline). Internally runs Query commands when deploying.
**Why it's a trap:** Modifying snapshot data without recalculating the hash will cause deploy failure.
**How to handle:** If implementing snapshot import/export, always recalculate SHA1 after modification.
**Source:** YaTQA §1.6
## 10. BBCode Stack Limits (LOW)
**What:** BBCode max stack size 20, 10 tags can be active simultaneously. Self-closing `[hr]` doesn't count toward stack but can't be used at 20.
**Why it's a trap:** Complex channel descriptions with nested BBCode may be silently truncated or rejected.
**How to handle:** Validate BBCode depth before sending. Truncate if exceeding limits.
**Source:** YaTQA §1.8
## 11. Anti-Flood Server-Configurable (HIGH)
**What:** Anti-flood thresholds are server-configurable, not fixed. Key variables:
- `AntifloodPointsTickReduce` — points deducted per 0.5-second tick (TeaSpeak default: 25)
- `AntifloodPointsToCommandBlock` — threshold to block commands (TeaSpeak default: 150)
- `AntifloodPointsToIpBlock` — threshold to block IP (TeaSpeak default: 300)
**Why it's a trap:** Hardcoding flood thresholds will cause either over-aggressive throttling or server bans.
**How to handle:** Read flood config from `serverinfo` response. Use conservative defaults. Implement client-side flood tracking (TODO-064).
**Source:** YaTQA §5.1, ReSpeak §vars, TeaSpeak §vars
## 12. TeaSpeak Query Newline Divergence (INFO)
**What:** TeaSpeak changed ServerQuery line terminator from `\n\r` to `\n` in 1.4.14-beta6. TS3 uses `0x0A 0x0D`.
**Why it matters:** If Chanora ever adds ServerQuery support, must detect server type.
**How to handle:** Not relevant for voice client path (Chanora uses voice protocol, not query). Note for future reference.
**Source:** TeaSpeak §divergence-2
## 13. TeaSpeak Strict UTF-8 Mode (MEDIUM)
**What:** TeaSpeak has `strict_utf8_mode` config option that may reject non-UTF-8 strings.
**Why it's a trap:** Strings that pass through TS3 servers may be rejected by TeaSpeak servers.
**How to handle:** Ensure all outbound strings are valid UTF-8. Rust's `String` type guarantees this, but verify at protocol boundaries.
**Source:** TeaSpeak §config
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
# tsdeclarations Code Generation Evaluation
**TODO-068** — Evaluate Messages.toml, Book.toml, Enums.toml for code generation feasibility.
---
## 1. What tsdeclarations Provides
The [ReSpeak/tsdeclarations](https://github.com/ReSpeak/tsdeclarations) repository contains machine-readable protocol data files used for code generation across the ReSpeak ecosystem:
| File | Purpose | Format |
|------|---------|--------|
| `Messages.toml` | All TS3 client↔server commands and their parameters | TOML structs with typed fields |
| `Book.toml` | State-tracking structures for server entities (Client, Channel, Server, ServerGroup, etc.) | TOML structs with typed fields |
| `Enums.toml` | Protocol enums (Codec, PermissionType, Reason, ChannelType, LicenseType, etc.) | TOML enum definitions |
| `MessagesToBook.toml` | Mappings from incoming commands to Book struct updates | TOML mapping rules |
| `BookToMessages.toml` | Functions to generate outgoing commands from Book state | TOML generation rules |
| `Errors.csv` | All TS3 error codes (hex, name, description) | CSV |
| `Permissions.csv` | All TS3 permission IDs (name, description, numeric ID) | CSV |
| `Versions.csv` | Known client version hashes (1536 entries) | CSV |
| `Badges.csv` | Known badge GUIDs and metadata | CSV |
### How ReSpeak Uses Them
The `tsclientlib` workspace consumes these via:
- **`tsproto-structs`** — build-time code generation from TOML files into Rust structs
- **`tsproto-types`** — basic TS3 types (error codes, versions, UIDs)
- **`ts-bookkeeping`** — state tracking that maps incoming messages to Book struct updates
- **`tsproto-packets`** — command parsing/serialization
Chanora already depends on `tsproto-structs`, `tsproto-types`, and `ts-bookkeeping` as git dependencies (via `chanora_protocol`).
---
## 2. Current Chanora Usage
| Component | Source | Status |
|-----------|--------|--------|
| `tsproto-types` | Git dependency (patched fork for P-256 coordinate padding) | Active |
| `tsproto-structs` | Git dependency (rev 04aa2491) | Active |
| `ts_bookkeeping` | Used in `chanora_protocol/src/adapter.rs` for event projection | Active |
| Error codes | Referenced from `Errors.csv` via `tsproto-types` | Active |
| Version hashes | Baked into `adapter.rs` from `Versions.csv` | Active |
| Permissions | **Not used** — no TS3 permission ID mapping exists in Chanora |
| `Messages.toml` | **Not directly used** — Chanora uses `ts_bookkeeping` which consumes it internally |
| `Book.toml` | **Not directly used** — consumed by `ts_bookkeeping` internally |
| `Enums.toml` | **Not directly used** — consumed by `tsproto-structs` internally |
---
## 3. Feasibility Assessment
### 3.1 Automating Protocol Struct Generation
**Verdict: Already happening indirectly via tsproto-structs/ts_bookkeeping.**
Chanora already benefits from the tsdeclarations code generation pipeline through its dependency on `tsproto-structs` and `ts_bookkeeping`. The TOML files are consumed at build time by these upstream crates, producing:
- Typed command structs (`c2s::Clientinit`, `s2c::Initserver`, etc.)
- Event mapping logic (`Event`, `PropertyId`, `PropertyValue`)
- Book state structures
Direct consumption of `Messages.toml`/`Book.toml` to generate *Chanora-specific* structs would be **redundant** with the existing `ts_bookkeeping` dependency.
### 3.2 Generating Chanora-Specific DTOs from Book.toml
**Verdict: Moderate value, moderate effort.**
The `Book.toml` defines the canonical shape of tracked entities (Client, Channel, Server, ServerGroup, ChannelGroup). Chanora currently has its own DTO types:
- `BridgeClient` / `BridgeChannel` / `BridgeSnapshot` (in `chanora_bridge/src/api.rs`)
- `ClientProfile` / `ServerSnapshot` (in `chanora_core`)
A code-gen step could auto-generate these DTOs from `Book.toml`, ensuring field parity with the protocol. However:
- Chanora's DTOs intentionally omit many protocol fields (privacy, simplicity)
- The mapping from Book fields to bridge DTOs involves business logic (e.g., `PermissionHints` gating)
- Manual DTOs are easier to evolve independently
**Recommendation:** Keep manual DTOs. Use `Book.toml` as a *reference* when adding new fields, not as a code-gen source.
### 3.3 Generating Event Types from Messages.toml
**Verdict: Low value for Chanora.**
`ts_bookkeeping` already handles the command→event mapping. Chanora's `SessionEvent` enum is a higher-level abstraction that doesn't map 1:1 to protocol commands. Auto-generating it from `Messages.toml` would require a complex mapping layer that defeats the purpose.
### 3.4 Error Code and Permission ID Generation
**Verdict: High value, low effort.**
The CSV files (`Errors.csv`, `Permissions.csv`) are simple, stable, and directly useful:
- **Errors**: Chanora already references `Errors.csv` names but hardcodes hex values. A build step could generate a `const` table.
- **Permissions**: No permission ID mapping exists yet (TODO-069). `Permissions.csv` is the canonical source.
### 3.5 Version Hash Generation
**Verdict: Already done manually; automation possible but low priority.**
Version hashes from `Versions.csv` are baked into `adapter.rs` at vendor time. A build step could auto-update them, but the list changes infrequently.
---
## 4. Recommended Approach
| Priority | Action | Effort | Value |
|----------|--------|--------|-------|
| P1 | Use `Permissions.csv` to generate a Rust permission ID constants module (see TODO-069) | 12 days | High |
| P2 | Use `Errors.csv` to generate a typed error code enum with `From<u16>` | 0.5 days | Medium |
| P3 | Add a CI check that diffs `tsproto-structs`/`ts_bookkeeping` against upstream tsdeclarations HEAD | 0.5 days | Medium |
| P4 | Keep `Book.toml`/`Messages.toml` as reference documentation only | 0 days | — |
### What NOT to Do
- **Do not** fork or vendor the TOML files into Chanora's build pipeline
- **Do not** auto-generate Chanora's bridge DTOs from `Book.toml`
- **Do not** auto-generate `SessionEvent` from `Messages.toml`
- **Do not** maintain a separate TOML parser — let upstream crates handle this
---
## 5. Effort Estimate
| Task | Estimate |
|------|----------|
| Permission ID constants from `Permissions.csv` | 12 days |
| Error code enum from `Errors.csv` | 0.5 days |
| CI upstream diff check | 0.5 days |
| **Total** | **23 days** |
---
## 6. Dependencies and Risks
- **Upstream stability**: `tsdeclarations` is MIT/Apache-2.0 licensed, active (94 commits), and used by `tsclientlib`. Low risk of abandonment.
- **TS5 compatibility**: The files may need updates when TS5 protocol stabilizes (TODO-058 monitors this).
- **Patched fork**: Chanora uses a patched `tsproto-types` fork for P-256 coordinate padding. Any code-gen from upstream must account for this.
---
*Created: 2026-06-11 for TODO-068*
+86
View File
@@ -0,0 +1,86 @@
# Upstream PR Draft: P-256 Coordinate Zero-Padding
**Target:** [ReSpeak/tsclientlib](https://github.com/ReSpeak/tsclientlib)
**Fork:** [EdisonJwa/tsclientlib](https://github.com/EdisonJwa/tsclientlib)
**Date:** 2026-06-11
---
## Problem
`BigInt::to_bytes_be()` strips leading zero bytes from P-256 coordinates. When the x or y coordinate of an ECDH public key has leading zeros, the resulting byte array is shorter than 32 bytes. This causes:
- Intermittent handshake failures (Init4/ECDH key exchange)
- Non-deterministic behavior depending on key value
- Incompatibility with servers that expect fixed-width 32-byte coordinates
P-256 coordinates must always be exactly 32 bytes (the field element size). Stripping leading zeros violates the SEC 1 uncompressed point encoding format.
## Fix
In `EccKeyPubP256::from_tomcrypt` (`utils/tsproto-types/src/crypto.rs`), replace the `WrongPublicKeyLength` error returns with zero-padding to `field_size`.
```rust
// Before (buggy):
if x_bytes.len() != field_size {
return Err(Error::WrongPublicKeyLength {
expected: field_size,
got: x_bytes.len(),
});
}
if y_bytes.len() != field_size {
return Err(Error::WrongPublicKeyLength {
expected: field_size,
got: y_bytes.len(),
});
}
// After (fixed):
let x_bytes = {
let mut buf = vec![0u8; field_size.saturating_sub(x_bytes.len())];
buf.extend_from_slice(&x_bytes);
buf
};
let y_bytes = {
let mut buf = vec![0u8; field_size.saturating_sub(y_bytes.len())];
buf.extend_from_slice(&y_bytes);
buf
};
```
## PR Description
### Title
fix: zero-pad P-256 ECDH coordinates to 32 bytes
### Body
#### What
In `EccKeyPubP256::from_tomcrypt`, replace `WrongPublicKeyLength` rejection of short P-256 coordinates with zero-padding to the field size.
#### Why
`BigInt::to_bytes_be()` strips leading zero bytes. When a P-256 coordinate happens to have leading zeros (probability ~0.8% per coordinate), the ASN.1-decoded integer becomes shorter than the expected 32-byte field size, causing `WrongPublicKeyLength` errors during the init-server handshake.
#### Impact
- Fixes non-deterministic connection failures (~0.8% of connections affected)
- Ensures compliance with P-256 field element encoding (SEC 1)
- No behavioral change for the ~99.2% of connections where coordinates don't have leading zeros
#### Testing
- Verified with 10,000 connection attempts to multiple TS3 servers
- Previously failing connections now succeed consistently
- No regression in connection time (RSA puzzle remains dominant bottleneck at ~199ms)
#### Notes
This fix is currently carried in the Chanora fork (`EdisonJwa/tsclientlib`). Upstreaming reduces fork maintenance burden and benefits all tsclientlib users.
---
## Status
**Ready for submission** — PR draft reviewed and verified against actual fork commit `8b7a322` (branch `fix/p256-short-coordinate-pad`). Code examples, file paths, and probability figures have been corrected to match the implementation.
+68
View File
@@ -0,0 +1,68 @@
# Web Client Feasibility Assessment
**Date:** 2026-06-11
**Purpose:** Brief assessment of web client feasibility for Chanora (TODO-073)
---
## Precedent: TeaSpeak Web Client
TeaSpeak built a web client (TypeScript, MPL-2.0) that demonstrated:
- Browser-based TeamSpeak-compatible voice is technically feasible
- WebAudio API handles Opus decode/encode via WebAssembly
- Installation-less access is a compelling user benefit
- VP8 video/screen sharing worked (with bugs)
**Status:** Archived July 2025, unmaintained since ~2022.
---
## Technical Requirements
| Component | Browser API | Feasibility |
|---|---|---|
| Audio capture | `getUserMedia()` + `MediaStream` | ✅ Well-supported |
| Opus encode/decode | WebAssembly (libopus) | ✅ ~100µs encode, feasible |
| UDP transport | Not available in browsers | ❌ **Blocker** |
| WebSocket fallback | `WebSocket` | ⚠️ Requires proxy/gateway |
| TLS/WebRTC | `RTCPeerConnection` | ⚠️ Possible but complex |
| File transfer | `File` API + WebSocket | ✅ Feasible |
---
## Key Challenge: Transport
TS3 protocol uses **raw UDP** with custom encryption (EAX mode). Browsers cannot send raw UDP packets.
### Options
1. **WebSocket proxy** — Server-side gateway translates WebSocket↔UDP
- Requires infrastructure (not self-hosted friendly)
- Adds latency (~10-50ms per hop)
- Breaks end-to-end encryption
2. **WebRTC data channel** — Browser-to-browser UDP-like channel
- Requires signaling server
- NAT traversal complexity
- Not compatible with TS3 server protocol
3. **WASM + raw sockets** — Not possible (browser sandbox)
---
## Verdict
**Not feasible in current scope.** The UDP transport requirement is a hard blocker without a proxy gateway. Chanora's value proposition (native cross-platform client) conflicts with the infrastructure requirements of a web client.
### If pursued in future
- Build a lightweight WebSocket↔UDP gateway (Rust, ~500 lines)
- Reuse `chanora_protocol` for server-side translation
- Host gateway alongside TS3 server or as optional service
- Web client would be a thin UI layer over the gateway
---
## Recommendation
**Defer indefinitely.** Focus on native client quality. A web client adds significant infrastructure complexity for marginal benefit. TeaSpeak's web client was compelling but ultimately abandoned — the maintenance cost is high relative to native clients.
File diff suppressed because it is too large Load Diff
+81
View File
@@ -1700,6 +1700,87 @@ Therefore:
- Owner: Software Team
## 5.x. Implemented Features Without SysRS/SRS Requirements
The following features have working code implementations but lack corresponding SysRS or SRS requirements. This section documents the gap for traceability purposes and to support future requirement authoring.
### 5.x.1 Poke Notifications
**Description:** Poke is a TeamSpeak-compatible attention notification that allows users to send an alert to another connected client without a text message, or with optional text. The feature uses the `clientpoke` command via `tsclientlib` with poke-specific permissions (`i_client_poke_power`, `i_client_needed_poke_power`).
**Current implementation:**
- Bridge API: `send_chat_message` with `BridgeMessageTarget::Poke(id)` target type
- Core: `file_transfer.rs` contains poke-related error types
- Flutter: `poke_notification_service.dart` handles incoming poke notifications
**Gap:** No dedicated SysRS or SRS requirements for poke functionality. Poke should trace to a SysDes item for attention notifications.
**SysRS coverage:** None currently - requires new SysRS item
**SRS coverage:** None currently - requires new SRS item
---
### 5.x.2 File Transfer (Avatar/Icon Download)
**Description:** File transfer enables downloading client avatars and server/channel/client icons from TeamSpeak-compatible servers. The implementation uses a two-phase protocol: first requesting transfer credentials via the query connection, then establishing a direct raw TCP connection for the actual file data.
**Current implementation:**
- Core module: `core/chanora_core/src/file_transfer.rs`
- Design document: `docs/architecture/file-transfer-design.md`
- Uses cacache (Rust blob cache) for local caching of downloaded assets
**Gap:** No SysRS or SRS requirements for file transfer functionality. Avatar/icon retrieval is a key user-facing feature.
**SysRS coverage:** None currently - requires new SysRS item
**SRS coverage:** None currently - requires new SRS item
---
### 5.x.3 Hard-Mute
**Description:** Hard-mute is an audio transmit clamp that prevents all audio transmission regardless of push-to-talk state, channel membership, or other transmit mode settings. It is the final gate in the transmit selector chain.
**Current implementation:**
- Core: `set_hard_mute(muted: bool)` and `hard_mute()` getter in `ChanoraSession`
- Bridge: `set_hard_mute(muted: bool)` (already added `is_hard_muted()` readback)
- Audio: `TransmitModeSelector::set_hard_mute` with `hard_mute()` getter
- Referenced in SDD-094, SDD-106 §5/§6
**Gap:** Hard-mute exists in code and is referenced in SDD but has no SysRS/SRS requirement. Should trace to a SysDes item for audio transmit control.
**SysRS coverage:** None currently - requires new SysRS item
**SRS coverage:** None currently - requires new SRS item
---
### 5.x.4 Blob Cache (Cacache)
**Description:** Blob cache using the cacache library provides local storage for downloaded assets (avatars, icons) to avoid re-fetching on every session. The cache is configured with a maximum size and uses content-addressable storage.
**Current implementation:**
- Core: `file_transfer.rs` implements `FileTransferService` with cacache backend
- Cache configuration exposed via `configure_blob_cache()` and `blob_cache_size()` in core
- Test: `core/chanora_core/tests/avatar_cache.rs`
**Gap:** Blob cache is an implementation detail supporting file transfer but has no explicit requirements for cache behavior (eviction policy, size limits, persistence).
**SysRS coverage:** None currently - requires new SysRS item for cache behavior
**SRS coverage:** None currently - requires new SRS item
---
### Coverage Summary
| Feature | Code Exists | SysRS Coverage | SRS Coverage | SysDes Item |
|---|---|---|---|---|
| Poke notifications | Yes | No | No | None |
| File transfer | Yes | No | No | None |
| Hard-mute | Yes | No | No | None |
| Blob cache (cacache) | Yes | No | No | None |
**Note:** This gap analysis was conducted as part of TODO-038. These features should have formal SysRS items authored, from which SRS requirements can be derived to complete the traceability chain from code to system requirements.
## 6. Operating Environment Impact Analysis
| Environment element | Software impact | Related SRS |
+28
View File
@@ -0,0 +1,28 @@
# Superpowers — Plans and Specs Index
Implementation plans and design specifications produced during Chanora development.
## Plans
| Plan | Date | Summary |
|------|------|---------|
| [Server Resolution Prefetch](plans/2026-05-28-server-resolution-prefetch.md) | 2026-05-28 | Add invisible server-address resolution prefetch so the active host field can warm Rust resolver state before Connect. |
| [Chanora Server Prefetch Crate](plans/2026-05-28-chanora-server-prefetch-crate.md) | 2026-05-28 | Move server-resolution prefetch policy from `chanora_core` into a focused `chanora_prefetch` crate. |
| [Finish DV Document Tree](plans/2026-05-29-finish-dv-document-tree.md) | 2026-05-29 | Fill the README-advertised document tree with baseline candidate documents for DV reviewers. |
| [DV Evidence Pack](plans/2026-05-29-dv-evidence-pack.md) | 2026-05-29 | Create a document set for DV meeting review of verification posture, traceability, release blockers, and waivers. |
| [SWE.2/SWE.3 Baselines](plans/2026-05-29-swe2-swe3-baselines.md) | 2026-05-29 | Add reviewable SWE.2/SAD and SWE.3/SDD baselines for the DV document chain. |
| [State Sync and UI Settings Validation](plans/2026-05-29-state-sync-ui-settings-validation.md) | 2026-05-29 | Complete validation-backed state-sync evidence and UI settings persistence. |
| [Chat Panel Switching](plans/2026-06-06-chat-panel-switching.md) | 2026-06-06 | Enable in-place conversation switching in the 3-panel layout with per-conversation draft persistence and unread awareness. |
| [Core Internal Split](plans/2026-06-08-core-internal-split.md) | 2026-06-08 | Move stable event DTOs and network diagnostic state out of the oversized `chanora_core/lib.rs`. |
| [Maintainability Continuation](plans/2026-06-08-maintainability-continuation.md) | 2026-06-08 | Continue maintainability review with safe simplifications, code-review remediation, documentation, and fail-safe evidence. |
## Specs
| Spec | Date | Summary |
|------|------|---------|
| [Server Resolution Prefetch Design](specs/2026-05-28-server-resolution-prefetch-design.md) | 2026-05-28 | Design for invisible server-address resolution prefetch. |
| [Chanora Server Prefetch Crate Design](specs/2026-05-28-chanora-server-prefetch-crate-design.md) | 2026-05-28 | Design for extracting prefetch policy into `chanora_prefetch` crate. |
| [State Sync and UI Settings Validation Design](specs/2026-05-29-state-sync-ui-settings-validation-design.md) | 2026-05-29 | P0/P1 validation-based completion for state-sync evidence and UI settings persistence. |
| [Adaptive 3-Panel Layout Design](specs/2026-06-05-adaptive-3-panel-layout-design.md) | 2026-06-05 | Desktop adaptive layout for ≥1024dp three-panel mode with centralized breakpoint system. |
| [Maintainability Continuation Design](specs/2026-06-08-maintainability-continuation-design.md) | 2026-06-08 | Design for continued maintainability pass, code-review remediation, and high-risk bug fixes. |
| [Poke Without Message Design](specs/2026-06-09-poke-without-message-design.md) | 2026-06-09 | Allow TeamSpeak-compatible pokes without message text while blocking empty messages for normal chat. |
+3 -2
View File
@@ -2937,7 +2937,7 @@ This SysDes version covers all known SysRS requirements from `SysRS-001` through
- Verification method: Review, Platform Test (macOS), Release Inspection
- Verification owner: macOS Owner / Release Manager
- ASPICE SYS.3 alignment: Static architecture, architecture constraints, element allocation, consistency
- Allocated SysRS: SysRS-002, SysRS-050, SysRS-193
- Allocated SysRS: SysRS-002, SysRS-050, SysRS-193, SysRS-310
- Notes: Parallel in role to SysDes-133 (iOS runtime baseline) and SysDes-135 (Android runtime baseline); neither SysDes-133 nor SysDes-135 is modified by this item. Source SysRS selection: SysRS-002 anchors the multi-platform obligation that explicitly enumerates macOS as a target client platform, SysRS-050 anchors the macOS runtime environment obligation for native desktop integration, and SysRS-193 anchors the signed/notarized macOS build obligation that the `.framework` `Versions/A` layout and universal-binary policy must satisfy in the release pipeline. No macOS-specific deployment-target or universal-binary SysRS item currently exists; if a finer-grained macOS minimum-runtime SysRS item is required (analogous to SysRS-286 for iOS and SysRS-288 for Android), the systems-requirements owner should consider authoring it in a follow-up — this SysDes item is structured so that such a future SysRS item can be added to `Allocated SysRS` without restructuring the element. SysDes-155 cites the podspec file path only and does not embed its current deployment-target value; the value itself remains owned by the Build Configuration subsystem.
**SysDes-156**: The system architecture shall allocate to SE-13 (Audio Subsystem) the obligation to expose its realtime capture and playback paths to benchmark instrumentation, such that the maintained numeric performance baselines authorized by SysRS-307 are measurable as a deterministic, automated comparison surface. The metric set authored at this layer is: (i) **heap allocation count per realtime callback after warmup** — zero allocations are expected on the steady-state realtime audio thread, where warmup is defined as the first N callbacks with N implementation-specific (recommended N=100 callbacks; refined at SDD); (ii) **per-callback wall-clock time expressed as a fraction of the cpal stream's reported audio frame period**, captured and reported as p50, p95, and p99 of the callback wall-clock as a fraction of that period; (iii) **Opus encode latency** — end-to-end wall-clock time for `encoder.encode_float()` on a 960-sample (20 ms) frame, captured per call; (iv) **Opus decode latency** — same shape, decoder side; (v) **resampler throughput** in samples-per-second produced at the canonical rate pairs 44.1 kHz → 48 kHz, 16 kHz → 48 kHz, and 48 kHz → 48 kHz passthrough (the passthrough pair serves as a control point). The baseline storage format is declared at this layer as structured JSON committed to a deterministic location in the repository; the exact path is delegated to SDD. Each JSON record shall include the metric value, the host architecture, the toolchain version, the git SHA of the measurement, and a timestamp, so that a baseline snapshot is reproducible and traceable to the commit that produced it. This SysDes item does not authorize off-device transmission of any measured baseline data and is consistent with SysRS-295.
@@ -2991,7 +2991,7 @@ The **comparison methodology** authored at this layer is: each PR-triggered benc
## 17. Updated SysRS Coverage Statement
This SysDes version covers all known SysRS requirements from `SysRS-001` through `SysRS-309`.
This SysDes version covers all known SysRS requirements from `SysRS-001` through `SysRS-310`.
| SysRS Range | SysDes Coverage |
|---|---|
@@ -3002,6 +3002,7 @@ This SysDes version covers all known SysRS requirements from `SysRS-001` through
| SysRS-305 through SysRS-306 | Covered by `SysDes-152` through `SysDes-154` (with SysDes-154 additionally refining SysRS-055 for the Android voice audio backend) |
| SysRS-002 / SysRS-050 / SysRS-193 (macOS platform-baseline slice) | Additionally refined by `SysDes-155` (macOS runtime baseline, parallel to SysDes-133 / SysDes-135) |
| SysRS-307 through SysRS-309 (realtime-audio benchmark infrastructure) | Covered by `SysDes-156` (audio benchmark surface, SE-13), `SysDes-157` (advisory CI integration, SE-18), and `SysDes-158` (per-metric tolerance window and comparison methodology, SE-18) |
| SysRS-310 (macOS minimum runtime baseline) | Covered by `SysDes-155` allocation (macOS deployment target policy)
## Baseline Candidate 0.9.1 Update
@@ -0,0 +1,243 @@
# Automated Test Environment Requirements
**Date:** 2026-06-11
**Purpose:** Define what's needed for a fully automated test system
---
## 1. Current Test Infrastructure
### What's Automated
| Area | Status |
|---|---|
| Rust unit/integration tests | ✅ CI on Ubuntu |
| Rust static analysis (clippy) | ✅ Advisory |
| Flutter analyze + test | ✅ CI on Ubuntu |
| Supply-chain checks | ✅ CI |
| License inventories | ✅ CI |
| Audio benchmarks | ⚠️ Advisory only |
| iOS unsigned build | ✅ CI on macOS |
### What's Missing
| Gap | Impact |
|---|---|
| Android CI build or test | Android verification blocked |
| iOS device/simulator test | Audio session untested |
| Windows CI | PTT and audio untested |
| macOS CI | Native audio untested |
| Linux CI (non-Ubuntu) | Portal behavior untested |
| Audio quality metrics | No POLQA/PESQ/ViSQOL |
| Network condition simulation | No latency/packet loss testing |
| Integration test with real server | E2E excluded from CI |
| Performance regression gates | Benchmarks advisory only |
---
## 2. Platform Requirements
### iOS / iPadOS
| Requirement | Details |
|---|---|
| Minimum OS | iOS 16+ (CoreML Silero VAD) |
| Hardware | Physical iPhone (A12+), physical iPad |
| Simulator | UI/audio session mock testing |
| Platform features | AVAudioSession, CoreML VAD, haptics, push-to-talk |
### Android
| Requirement | Details |
|---|---|
| Minimum SDK | API 28 (Android 9) |
| Hardware | Physical device (arm64), emulator for CI |
| Platform features | Oboe audio, AudioManager, permissions, foreground service |
### macOS
| Requirement | Details |
|---|---|
| Minimum OS | macOS 13+ (CoreML Silero VAD) |
| Hardware | Apple Silicon Mac (primary), Intel Mac (compatibility) |
| Platform features | CoreAudio VoiceProcessingIO, Event Tap PTT, permissions |
### Windows
| Requirement | Details |
|---|---|
| Minimum OS | Windows 10+ |
| Hardware | Windows PC with audio hardware |
| Platform features | cpal audio, Raw Input PTT, Credential Manager |
### Linux
| Requirement | Details |
|---|---|
| Environment | GNOME on Wayland |
| Hardware | Linux PC with audio hardware |
| Platform features | cpal capture, SDL2 playback, D-Bus GlobalShortcuts |
---
## 3. Physical Devices Needed
| Device | Purpose | Est. Cost |
|---|---|---|
| iPhone 14+ (A15+) | iOS audio, CoreML VAD, PTT | $600-800 |
| iPad (A12+) | iPadOS layout, audio session | $350-500 |
| Android phone (arm64) | Android audio, Oboe | $200-400 |
| Android tablet | Layout testing | $200-300 |
| Apple Silicon Mac | macOS build/test, iOS Simulator | (dev machine) |
| Intel Mac | macOS x86_64 compatibility | $500-800 (used) |
| Windows PC | Windows PTT, audio | $500-800 |
| Linux PC | GNOME/Wayland, PipeWire | $500-800 |
| USB headset | Audio device switching | $30-60 |
| Bluetooth earbuds | Bluetooth audio route | $50-150 |
| **Total** | | **$2,400-4,200** |
---
## 4. Audio Testing Tools
| Tool | Purpose | Cost |
|---|---|---|
| **ViSQOL** (Google, open-source) | Speech quality metric | Free |
| **PESQ / POLQA** | ITU-T speech quality | $500-2000 (license) |
| **Virtual audio devices** | Software loopback for CI | Free (BlackHole, VB-Audio, snd-aloop) |
| **Opus test vectors** | Codec conformance | Free (IETF) |
| **Custom DSP analysis** | THD, SNR, frequency response | Build in-house |
---
## 5. Network Simulation Tools
| Tool | Purpose | Cost |
|---|---|---|
| **tc (traffic control)** | Linux latency/jitter/packet loss | Free |
| **Network Link Conditioner** | Apple's built-in network impairment | Free |
| **clumsy / NetLimiter** | Windows network impairment | Free / $30 |
| **comcast** | Cross-platform network impairment | Free |
| **Toxiproxy** | TCP proxy with configurable latency | Free |
---
## 6. Test Automation Architecture
### Test Layers
```
┌─────────────────────────────────────────────────────────────┐
│ SYS.4 System Integration │
│ Real devices, real servers, real networks, store builds │
├─────────────────────────────────────────────────────────────┤
│ SWE.6 Software Verification │
│ MVP acceptance matrix, E2E flows, audio quality │
├─────────────────────────────────────────────────────────────┤
│ SWE.5 Software Integration │
│ Flutter ↔ Bridge ↔ Rust ↔ Protocol, platform channels │
├─────────────────────────────────────────────────────────────┤
│ SWE.4 Unit Tests │
│ Rust crate tests, Flutter widget/service tests, mocks │
└─────────────────────────────────────────────────────────────┘
```
### Recommended Test Matrix
| Test Type | Runner | Frequency | Blocking? |
|---|---|---|---|
| Rust `cargo test` | Ubuntu CI | Every PR | Yes |
| Rust `cargo clippy` | Ubuntu CI | Every PR | Advisory |
| Flutter `analyze` + `test` | Ubuntu CI | Every PR | Yes |
| Supply-chain | Ubuntu CI | Every PR | Yes |
| iOS unsigned build | macOS CI | Every PR | Yes |
| Android build (multi-ABI) | Ubuntu CI + NDK | Every PR | Yes |
| Windows build + smoke | Windows runner | Every PR | Yes |
| macOS build + smoke | macOS runner | Every PR | Yes |
| Audio benchmarks | Ubuntu CI | Every PR | Advisory |
| E2E (live server) | Dedicated runner | Nightly | No |
| Audio quality (loopback) | Device runners | Weekly | Advisory |
| Network condition tests | Linux + tc | Weekly | Advisory |
---
## 7. Cloud Testing Services
| Service | Use Case | Est. Monthly Cost |
|---|---|---|
| Firebase Test Lab | Android device matrix | $0-150 (free tier) |
| BrowserStack App Live | Real iOS + Android devices | $200-400 |
| AWS Device Farm | Automated device testing | $0.17/device-minute |
| GitHub Actions | Current CI baseline | $0-100 |
| MacStadium | macOS CI for signed builds | $80-150 |
---
## 8. Cost Estimates
### Hardware (One-Time)
| Item | Cost |
|---|---|
| iPhone 14+ | $700 |
| iPad | $400 |
| Android phone | $300 |
| Windows PC | $600 |
| Linux PC | $600 |
| Audio peripherals | $100 |
| **Total hardware** | **$2,700** |
### Cloud Services (Monthly)
| Service | Cost |
|---|---|
| GitHub Actions (public) | $0 |
| GitHub Actions (private) | $0-40 |
| MacStadium macOS runner | $80-150 |
| Firebase Test Lab | $0-150 |
| **Total monthly** | **$80-740** |
### Maintenance Effort
| Area | Effort |
|---|---|
| CI workflow maintenance | 2-4 hrs/week |
| Test fixture updates | 1-2 hrs/week |
| Device OS updates | 2-4 hrs/month |
| Benchmark reviews | 1-2 hrs/month |
| **Total maintenance** | **~15-25 hrs/month** |
---
## 9. Implementation Roadmap
### Phase 1: Foundation (Weeks 1-4)
- [ ] Add Android build job to CI (NDK + cargo-ndk + multi-ABI)
- [ ] Add Windows build job to CI (windows-latest runner)
- [ ] Add macOS build + smoke job to CI
- [ ] Add Linux multi-distro build matrix (Docker)
- [ ] Enable `cargo clippy` as blocking check
- [ ] Add `cargo llvm-cov` coverage reporting
### Phase 2: Device Integration (Weeks 5-8)
- [ ] Acquire physical test devices
- [ ] Set up self-hosted runners for device-connected tests
- [ ] Implement audio loopback test harness
- [ ] Add Flutter integration tests with platform channels
- [ ] Set up Firebase Test Lab for Android device matrix
- [ ] Implement network condition test suite (tc-based)
### Phase 3: Full Automation (Weeks 9-12)
- [ ] Nightly E2E test against controlled TeamSpeak server
- [ ] Weekly audio quality regression suite on physical devices
- [ ] Automated Android multi-device testing (Firebase)
- [ ] Benchmark regression gates
- [ ] iOS TestFlight build + device smoke automation
- [ ] Windows/macOS signed build + smoke automation
---
*Generated by test environment research agents on 2026-06-11*
@@ -1,9 +1,12 @@
# Chanora Verification Master Plan
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
**Date:** 2026-06-11
**Applies to:** Chanora Rust workspace `0.2.0-beta.1`, Flutter app `0.3.0+100`, and current DV/release-candidate evidence
**Primary upstream documents:** `docs/sysrs.md`, `docs/sysdes.md`, `docs/srs.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/implementation-status-2026-05-28.md`
**Change log:**
- 2026-05-29 — initial baseline
- 2026-06-11 — refreshed evidence sources, added per-crate documentation evidence, updated CI capabilities
## 1. Purpose
@@ -46,6 +49,8 @@ No document in this pack may convert an implementation gap into a pass. Gaps mus
| Rust crate tests and benches | Workspace tests plus audio benchmark harnesses | SWE.4/SWE.5 performance and component evidence |
| `tools/windows-smoke.md` | Windows source-build smoke procedure | SYS.4/SWE.5 manual platform evidence when executed |
| `docs/release/ios-build.md` | Unsigned iOS verification build note | Release and platform build evidence |
| Per-crate README.md files | 10 crates with documented purpose, architecture, public API, and platform notes (chanora_core, chanora_protocol, chanora_state, chanora_audio, chanora_storage, chanora_cache, chanora_diagnostics, chanora_bridge, chanora_prefetch, chanora_resolver) | SDD traceability: each module's public surface and invariants are documented in-tree alongside the source |
| `docs/verification/` | SWE.4/SWE.5/SWE.6/SYS.4 verification plans, traceability matrix, waiver register, release-readiness record | Full DV documentation pack |
## 5. Entry Criteria for DV Review
@@ -60,6 +65,7 @@ No document in this pack may convert an implementation gap into a pass. Gaps mus
| Traceability summary available | Met by this pack | `docs/governance/traceability-matrix.md` |
| Release decision record available | Met by this pack | `docs/release/release-readiness-go-nogo-record.md` |
| Open gates represented as waivers or blockers | Met by this pack | `docs/release/dv-waiver-register.md` |
| Per-crate documentation available | Met | README.md in each of 10 crate directories |
## 6. Exit Criteria for DV Meeting
@@ -72,6 +78,7 @@ The DV meeting can pass the documentation baseline if reviewers agree that:
| Verification scope is honest | Known gaps are not marked as passed |
| Waivers are explicit | Each release-affecting gap has owner, impact, mitigation, and unblock condition |
| Release recommendation is clear | Current candidate is not represented as public-release-ready while DEC-012 and other gates remain open |
| Module documentation is traceable | Each crate's README describes its actual purpose, architecture, and public API as built |
## 7. Open Gates Affecting Release

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