Compare commits
83 Commits
feature/ws
...
6be36e43c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be36e43c2 | ||
|
|
2f2720802d | ||
|
|
087bfd2335 | ||
|
|
0a05e62c7f | ||
|
|
b97f32ce46 | ||
|
|
d66d583583 | ||
|
|
d06cf66538 | ||
|
|
c8bcc5c974 | ||
|
|
760126b6ab | ||
|
|
53f8bf8fff | ||
|
|
b3cdad0c75 | ||
|
|
fa3c7f1cef | ||
|
|
68b56d9172 | ||
|
|
7973c8c6a3 | ||
|
|
3e9539e5da | ||
|
|
a1ccb3f390 | ||
|
|
7751439e2b | ||
|
|
20bc290c18 | ||
|
|
a8dc350a65 | ||
|
|
00fa109f07 | ||
|
|
1e40dec468 | ||
|
|
aecef0905d | ||
|
|
18f7faa279 | ||
|
|
eeb85aeac2 | ||
|
|
00b405aa87 | ||
|
|
d09e21965e | ||
|
|
97bcc79f9b | ||
|
|
264ef9c4d4 | ||
|
|
a9adb5cfd7 | ||
|
|
a39b074d6e | ||
|
|
9cab6e2347 | ||
|
|
5e93cb74f2 | ||
|
|
b56b4a759c | ||
|
|
6f99841cc7 | ||
|
|
3b0811ce2e | ||
|
|
9eed94850d | ||
|
|
5e9718aeb2 | ||
|
|
3093933602 | ||
|
|
4c6c909732 | ||
|
|
33fab9a049 | ||
|
|
31d2306915 | ||
|
|
4af7c5f94c | ||
|
|
6597b5bd86 | ||
|
|
ae9d8526dd | ||
|
|
4d54b6f9e4 | ||
|
|
2b3bdae440 | ||
|
|
20922455bd | ||
|
|
e6564bab57 | ||
|
|
aebf9156c0 | ||
|
|
9bbaec6b35 | ||
|
|
dc66b60d18 | ||
|
|
a9c4260b4e | ||
|
|
7eb136fcb3 | ||
|
|
550a124972 | ||
|
|
0835c36d0f | ||
|
|
6228ab32c1 | ||
|
|
bd258f432a | ||
|
|
8bf073aa80 | ||
|
|
72e834b45e | ||
|
|
673ffd498c | ||
|
|
2d4b8eebd5 | ||
|
|
a23d9f5e41 | ||
|
|
b3e56ecbd8 | ||
|
|
2fa07286c3 | ||
|
|
bf91cf25bd | ||
|
|
81c756c076 | ||
|
|
af85a49e86 | ||
|
|
bae03365da | ||
|
|
9d9ce4706d | ||
|
|
9098e28a1f | ||
|
|
f6d51fce61 | ||
|
|
a8dd0c2f57 | ||
|
|
64566e9acb | ||
|
|
10eb19cd24 | ||
|
|
778f4dd428 | ||
|
|
622fdee51f | ||
|
|
b204213a01 | ||
|
|
e751af7e38 | ||
|
|
8d5f6fe044 | ||
|
|
780309fede | ||
|
|
73ebcdd869 | ||
|
|
e7b1c3372a | ||
|
|
26e9c55f1f |
72
.agents/skills/caveman/SKILL.md
Normal file
72
.agents/skills/caveman/SKILL.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
||||
when token efficiency is requested.
|
||||
---
|
||||
|
||||
# Caveman Mode
|
||||
|
||||
## Core Rule
|
||||
|
||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
||||
|
||||
## Grammar
|
||||
|
||||
- Drop articles (a, an, the)
|
||||
- Drop filler (just, really, basically, actually, simply)
|
||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
||||
- No hedging (skip "it might be worth considering")
|
||||
- Fragments fine. No need full sentence
|
||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
||||
- Code blocks unchanged. Caveman speak around code, not in code
|
||||
- Error messages quoted exact. Caveman only for explanation
|
||||
|
||||
## Pattern
|
||||
|
||||
```
|
||||
[thing] [action] [reason]. [next step].
|
||||
```
|
||||
|
||||
Not:
|
||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
||||
|
||||
Yes:
|
||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
||||
|
||||
## Examples
|
||||
|
||||
**User:** Why is my React component re-rendering?
|
||||
|
||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
||||
|
||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
|
||||
---
|
||||
|
||||
**User:** How do I set up a PostgreSQL connection pool?
|
||||
|
||||
**Caveman:**
|
||||
```
|
||||
Use `pg` pool:
|
||||
```
|
||||
```js
|
||||
const pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
```
|
||||
```
|
||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Code: write normal. Caveman English only
|
||||
- Git commits: normal
|
||||
- PR descriptions: normal
|
||||
- User say "stop caveman" or "normal mode": revert immediately
|
||||
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[target.aarch64-linux-android]
|
||||
linker = "aarch64-linux-android26-clang"
|
||||
|
||||
[target.armv7-linux-androideabi]
|
||||
linker = "armv7a-linux-androideabi26-clang"
|
||||
@@ -2,187 +2,57 @@ name: Build Release Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/*'
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore:
|
||||
- '.gitea/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
targets:
|
||||
description: 'Targets to build (comma-separated: amd64,arm64,armv7,mac-arm64)'
|
||||
required: false
|
||||
default: 'amd64'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# Always builds on push tags. On manual dispatch, reads inputs.
|
||||
build-amd64:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
contains(github.event.inputs.targets, 'amd64')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1-bookworm
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: apt-get update && apt-get install -y cmake pkg-config libasound2-dev
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-amd64-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-amd64-
|
||||
|
||||
- name: Build headless binaries
|
||||
run: cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Build audio client
|
||||
- name: Init submodules
|
||||
run: |
|
||||
cargo build --release --bin wzp-client --features audio
|
||||
cp target/release/wzp-client target/release/wzp-client-audio
|
||||
cargo build --release --bin wzp-client
|
||||
git config --global url."https://git.manko.yoga/".insteadOf "ssh://git@git.manko.yoga:222/"
|
||||
git submodule update --init --recursive
|
||||
|
||||
- name: Install Rust + dependencies
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
apt-get update && apt-get install -y cmake pkg-config libasound2-dev ninja-build
|
||||
rustc --version
|
||||
|
||||
- name: Build relay + tools
|
||||
run: |
|
||||
source "$HOME/.cargo/env"
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist/wzp-linux-amd64
|
||||
cp target/release/wzp-relay dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-client dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-client-audio dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-web dist/wzp-linux-amd64/
|
||||
cp target/release/wzp-bench dist/wzp-linux-amd64/
|
||||
cp -r crates/wzp-web/static dist/wzp-linux-amd64/
|
||||
cd dist && tar czf wzp-linux-amd64.tar.gz wzp-linux-amd64/
|
||||
source "$HOME/.cargo/env"
|
||||
cargo test --workspace --lib
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wzp-linux-amd64
|
||||
path: dist/wzp-linux-amd64.tar.gz
|
||||
|
||||
build-arm64:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
contains(github.event.inputs.targets, 'arm64')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
dpkg --add-architecture arm64
|
||||
apt-get update
|
||||
apt-get install -y cmake pkg-config gcc-aarch64-linux-gnu libc6-dev-arm64-cross
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-arm64-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-arm64-
|
||||
|
||||
- name: Build
|
||||
- name: Upload to rustypaste
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
PASTE_AUTH: ${{ secrets.PASTE_AUTH }}
|
||||
PASTE_URL: ${{ secrets.PASTE_URL }}
|
||||
run: |
|
||||
cargo build --release --target aarch64-unknown-linux-gnu \
|
||||
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist/wzp-linux-arm64
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-relay dist/wzp-linux-arm64/
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-client dist/wzp-linux-arm64/
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-web dist/wzp-linux-arm64/
|
||||
cp target/aarch64-unknown-linux-gnu/release/wzp-bench dist/wzp-linux-arm64/
|
||||
cp -r crates/wzp-web/static dist/wzp-linux-arm64/
|
||||
cd dist && tar czf wzp-linux-arm64.tar.gz wzp-linux-arm64/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wzp-linux-arm64
|
||||
path: dist/wzp-linux-arm64.tar.gz
|
||||
|
||||
build-armv7:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
contains(github.event.inputs.targets, 'armv7')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
dpkg --add-architecture armhf
|
||||
apt-get update
|
||||
apt-get install -y cmake pkg-config gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-armv7-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-armv7-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
CC_armv7_unknown_linux_gnueabihf: arm-linux-gnueabihf-gcc
|
||||
run: |
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf \
|
||||
--bin wzp-relay --bin wzp-client --bin wzp-bench --bin wzp-web
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir -p dist/wzp-linux-armv7
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-relay dist/wzp-linux-armv7/
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-client dist/wzp-linux-armv7/
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-web dist/wzp-linux-armv7/
|
||||
cp target/armv7-unknown-linux-gnueabihf/release/wzp-bench dist/wzp-linux-armv7/
|
||||
cp -r crates/wzp-web/static dist/wzp-linux-armv7/
|
||||
cd dist && tar czf wzp-linux-armv7.tar.gz wzp-linux-armv7/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wzp-linux-armv7
|
||||
path: dist/wzp-linux-armv7.tar.gz
|
||||
|
||||
# Release job — creates a release with all artifacts when a tag is pushed
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/**/*.tar.gz
|
||||
generate_release_notes: true
|
||||
tar czf /tmp/wzp-linux-amd64.tar.gz \
|
||||
-C target/release wzp-relay wzp-client wzp-web wzp-bench
|
||||
ls -lh /tmp/wzp-linux-amd64.tar.gz
|
||||
LINK=$(curl -sF "file=@/tmp/wzp-linux-amd64.tar.gz" \
|
||||
-H "Authorization: ${PASTE_AUTH}" \
|
||||
"https://${PASTE_URL}")
|
||||
echo "Download: ${LINK}"
|
||||
|
||||
43
.gitea/workflows/mirror-github.yml
Normal file
43
.gitea/workflows/mirror-github.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/*'
|
||||
- 'feature/*'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
git remote add github git@github.com:manawenuz/wzp.git
|
||||
|
||||
# Push the current branch
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
||||
echo "Pushing tag: ${TAG}"
|
||||
git push github "refs/tags/${TAG}" --force
|
||||
else
|
||||
echo "Pushing branch: ${BRANCH}"
|
||||
git push github "HEAD:refs/heads/${BRANCH}" --force
|
||||
fi
|
||||
104
Cargo.lock
generated
104
Cargo.lock
generated
@@ -43,6 +43,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -841,6 +847,27 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -1754,6 +1781,15 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1796,6 +1832,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
@@ -2074,6 +2119,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
@@ -2414,6 +2465,17 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -3325,6 +3387,17 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12612be8f868a09c0ceae7113ff26afe79d81a24473a393cb9120ece162e86c0"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -3363,10 +3436,14 @@ version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
@@ -4179,6 +4256,32 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "wzp-android"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"cc",
|
||||
"jni",
|
||||
"libc",
|
||||
"rand 0.8.5",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-android",
|
||||
"tracing-subscriber",
|
||||
"wzp-codec",
|
||||
"wzp-crypto",
|
||||
"wzp-fec",
|
||||
"wzp-proto",
|
||||
"wzp-transport",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wzp-client"
|
||||
version = "0.1.0"
|
||||
@@ -4267,6 +4370,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"bytes",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"prometheus",
|
||||
"quinn",
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/wzp-relay",
|
||||
"crates/wzp-client",
|
||||
"crates/wzp-web",
|
||||
"crates/wzp-android",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -39,7 +40,7 @@ codec2 = "0.3"
|
||||
|
||||
# Crypto
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
6
android/.gitignore
vendored
Normal file
6
android/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
app/src/main/jniLibs/
|
||||
local.properties
|
||||
keystore/*.jks
|
||||
BIN
android/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so
Executable file
BIN
android/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so
Executable file
Binary file not shown.
85
android/app/build.gradle.kts
Normal file
85
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,85 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.wzp.phone"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.wzp.phone"
|
||||
minSdk = 26 // AAudio requires API 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
ndk { abiFilters += listOf("arm64-v8a") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("${project.rootDir}/keystore/wzp-release.jks")
|
||||
storePassword = "wzphone2024"
|
||||
keyAlias = "wzp-release"
|
||||
keyPassword = "wzphone2024"
|
||||
}
|
||||
getByName("debug") {
|
||||
storeFile = file("${project.rootDir}/keystore/wzp-debug.jks")
|
||||
storePassword = "android"
|
||||
keyAlias = "wzp-debug"
|
||||
keyPassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isDebuggable = true
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
buildFeatures { compose = true }
|
||||
composeOptions { kotlinCompilerExtensionVersion = "1.5.8" }
|
||||
|
||||
ndkVersion = "26.1.10909125"
|
||||
}
|
||||
|
||||
// cargo-ndk integration: build the Rust native library for Android targets
|
||||
tasks.register<Exec>("cargoNdkBuild") {
|
||||
workingDir = file("${project.rootDir}/..")
|
||||
commandLine(
|
||||
"cargo", "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"-o", "${project.projectDir}/src/main/jniLibs",
|
||||
"build", "--release", "-p", "wzp-android"
|
||||
)
|
||||
}
|
||||
|
||||
// Skip cargo-ndk in CI/Docker — .so is pre-built into jniLibs
|
||||
// tasks.named("preBuild") { dependsOn("cargoNdkBuild") }
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
}
|
||||
9
android/app/proguard-rules.pro
vendored
Normal file
9
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# WZPhone ProGuard rules
|
||||
|
||||
# Keep JNI native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep the WZP engine bridge class
|
||||
-keep class com.wzp.phone.engine.** { *; }
|
||||
43
android/app/src/main/AndroidManifest.xml
Normal file
43
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name="com.wzp.WzpApplication"
|
||||
android:label="WZ Phone"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name="com.wzp.ui.call.CallActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.wzp.service.CallService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
0
android/app/src/main/java/com/wzp/.gitkeep
Normal file
0
android/app/src/main/java/com/wzp/.gitkeep
Normal file
38
android/app/src/main/java/com/wzp/WzpApplication.kt
Normal file
38
android/app/src/main/java/com/wzp/WzpApplication.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.wzp
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Application entry point for WarzonePhone.
|
||||
*
|
||||
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
|
||||
*/
|
||||
class WzpApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Active Call",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shown while a VoIP call is in progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "wzp_call_channel"
|
||||
}
|
||||
}
|
||||
359
android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
Normal file
359
android/app/src/main/java/com/wzp/audio/AudioPipeline.kt
Normal file
@@ -0,0 +1,359 @@
|
||||
package com.wzp.audio
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.wzp.engine.WzpEngine
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStreamWriter
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Audio pipeline that captures mic audio and plays received audio using
|
||||
* Android AudioRecord/AudioTrack APIs running on JVM threads.
|
||||
*
|
||||
* PCM samples are shuttled to/from the Rust engine via JNI ring buffers:
|
||||
* - Capture: AudioRecord → WzpEngine.writeAudio() → Rust encoder → network
|
||||
* - Playout: network → Rust decoder → WzpEngine.readAudio() → AudioTrack
|
||||
*
|
||||
* All audio is 48kHz, mono, 16-bit PCM (matching Opus codec requirements).
|
||||
*/
|
||||
class AudioPipeline(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AudioPipeline"
|
||||
private const val SAMPLE_RATE = 48000
|
||||
private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO
|
||||
private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
|
||||
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
||||
/** 20ms frame at 48kHz = 960 samples */
|
||||
private const val FRAME_SAMPLES = 960
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var running = false
|
||||
/** Playout (incoming voice) gain in dB. 0 = unity. */
|
||||
@Volatile
|
||||
var playoutGainDb: Float = 0f
|
||||
/** Capture (mic) gain in dB. 0 = unity. */
|
||||
@Volatile
|
||||
var captureGainDb: Float = 0f
|
||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||
var aecEnabled: Boolean = true
|
||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||
var debugRecording: Boolean = false
|
||||
private var captureThread: Thread? = null
|
||||
private var playoutThread: Thread? = null
|
||||
|
||||
// DirectByteBuffers for zero-copy JNI audio transfer.
|
||||
// Allocated as class fields (NOT locals) because ART's JIT OSR
|
||||
// can null local variables when it replaces the stack frame mid-loop.
|
||||
// These survive OSR because they're on the heap.
|
||||
private val captureDirectBuf: ByteBuffer =
|
||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
private val playoutDirectBuf: ByteBuffer =
|
||||
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
/** Latch counted down by each audio thread after exiting its loop.
|
||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||
private var drainLatch: CountDownLatch? = null
|
||||
|
||||
private val debugDir: File by lazy {
|
||||
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
fun start(engine: WzpEngine) {
|
||||
if (running) return
|
||||
running = true
|
||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
||||
|
||||
captureThread = Thread({
|
||||
runCapture(engine)
|
||||
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
|
||||
// Park thread forever — exiting triggers a libcrypto TLS destructor
|
||||
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
|
||||
parkThread()
|
||||
}, "wzp-capture").apply {
|
||||
isDaemon = true
|
||||
priority = Thread.MAX_PRIORITY
|
||||
start()
|
||||
}
|
||||
|
||||
playoutThread = Thread({
|
||||
runPlayout(engine)
|
||||
drainLatch?.countDown() // signal: playout loop exited
|
||||
parkThread()
|
||||
}, "wzp-playout").apply {
|
||||
isDaemon = true
|
||||
priority = Thread.MAX_PRIORITY
|
||||
start()
|
||||
}
|
||||
|
||||
Log.i(TAG, "audio pipeline started")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
// Don't join threads — they are parked as daemons to avoid native TLS crash.
|
||||
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
|
||||
Log.i(TAG, "audio pipeline stopped (running=false)")
|
||||
}
|
||||
|
||||
/** Block until both audio threads have exited their loops (max 200ms).
|
||||
* After this returns, no more JNI calls to the engine will be made. */
|
||||
fun awaitDrain(): Boolean {
|
||||
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
||||
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
|
||||
captureThread = null
|
||||
playoutThread = null
|
||||
drainLatch = null
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
|
||||
if (db == 0f) return
|
||||
val linear = 10f.pow(db / 20f)
|
||||
for (i in 0 until count) {
|
||||
pcm[i] = (pcm[i] * linear).toInt().coerceIn(-32000, 32000).toShort()
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeRms(pcm: ShortArray, count: Int): Int {
|
||||
var sumSq = 0.0
|
||||
for (i in 0 until count) {
|
||||
val s = pcm[i].toDouble()
|
||||
sumSq += s * s
|
||||
}
|
||||
return sqrt(sumSq / count).toInt()
|
||||
}
|
||||
|
||||
private fun parkThread() {
|
||||
try {
|
||||
Thread.sleep(Long.MAX_VALUE)
|
||||
} catch (_: InterruptedException) {
|
||||
// process exiting
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCapture(engine: WzpEngine) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.e(TAG, "RECORD_AUDIO permission not granted, capture disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING)
|
||||
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4) // at least 4 frames
|
||||
|
||||
val recorder = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_IN,
|
||||
ENCODING,
|
||||
bufSize
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "AudioRecord SecurityException: ${e.message}")
|
||||
return
|
||||
}
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord failed to initialize")
|
||||
recorder.release()
|
||||
return
|
||||
}
|
||||
|
||||
// Attach hardware AEC if available and enabled in settings
|
||||
var aec: AcousticEchoCanceler? = null
|
||||
var ns: NoiseSuppressor? = null
|
||||
if (aecEnabled) {
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
try {
|
||||
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
|
||||
aec?.enabled = true
|
||||
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AEC init failed: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "AEC not available on this device")
|
||||
}
|
||||
|
||||
// Attach hardware noise suppressor if available
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
try {
|
||||
ns = NoiseSuppressor.create(recorder.audioSessionId)
|
||||
ns?.enabled = true
|
||||
Log.i(TAG, "NoiseSuppressor enabled")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "AEC disabled by user setting")
|
||||
}
|
||||
|
||||
recorder.startRecording()
|
||||
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES)
|
||||
// Debug: PCM file + RMS CSV
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var frameIdx = 0L
|
||||
if (debugRecording) {
|
||||
try {
|
||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
|
||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
|
||||
rmsCsv.write("frame,time_ms,rms\n")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "debug recording init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (running) {
|
||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
applyGain(pcm, read, captureGainDb)
|
||||
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
|
||||
captureDirectBuf.clear()
|
||||
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
|
||||
engine.writeAudioDirect(captureDirectBuf, read)
|
||||
|
||||
// Debug: write raw PCM + RMS
|
||||
if (pcmOut != null) {
|
||||
byteConv.clear()
|
||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
||||
}
|
||||
if (rmsCsv != null) {
|
||||
val rms = computeRms(pcm, read)
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
||||
}
|
||||
frameIdx++
|
||||
} else if (read < 0) {
|
||||
Log.e(TAG, "AudioRecord.read error: $read")
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pcmOut?.close()
|
||||
rmsCsv?.close()
|
||||
recorder.stop()
|
||||
aec?.release()
|
||||
ns?.release()
|
||||
recorder.release()
|
||||
Log.i(TAG, "capture stopped (frames=$frameIdx)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPlayout(engine: WzpEngine) {
|
||||
val minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING)
|
||||
val bufSize = maxOf(minBuf, FRAME_SAMPLES * 2 * 4)
|
||||
|
||||
val track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(CHANNEL_OUT)
|
||||
.setEncoding(ENCODING)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(bufSize)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioTrack failed to initialize")
|
||||
track.release()
|
||||
return
|
||||
}
|
||||
|
||||
track.play()
|
||||
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES)
|
||||
val silence = ShortArray(FRAME_SAMPLES)
|
||||
// Debug: PCM file + RMS CSV for playout
|
||||
var pcmOut: BufferedOutputStream? = null
|
||||
var rmsCsv: OutputStreamWriter? = null
|
||||
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||
var frameIdx = 0L
|
||||
if (debugRecording) {
|
||||
try {
|
||||
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
|
||||
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
|
||||
rmsCsv.write("frame,time_ms,rms\n")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "debug playout recording init failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (running) {
|
||||
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
|
||||
playoutDirectBuf.clear()
|
||||
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
|
||||
if (read >= FRAME_SAMPLES) {
|
||||
playoutDirectBuf.rewind()
|
||||
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
|
||||
applyGain(pcm, read, playoutGainDb)
|
||||
track.write(pcm, 0, read)
|
||||
|
||||
// Debug: write raw PCM + RMS
|
||||
if (pcmOut != null) {
|
||||
byteConv.clear()
|
||||
for (i in 0 until read) byteConv.putShort(pcm[i])
|
||||
pcmOut.write(byteConv.array(), 0, read * 2)
|
||||
}
|
||||
if (rmsCsv != null) {
|
||||
val rms = computeRms(pcm, read)
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
|
||||
}
|
||||
frameIdx++
|
||||
} else {
|
||||
track.write(silence, 0, FRAME_SAMPLES)
|
||||
// Log silence frames to RMS as 0
|
||||
if (rmsCsv != null) {
|
||||
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
|
||||
rmsCsv.write("$frameIdx,$timeMs,0\n")
|
||||
}
|
||||
frameIdx++
|
||||
Thread.sleep(5)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pcmOut?.close()
|
||||
rmsCsv?.close()
|
||||
track.stop()
|
||||
track.release()
|
||||
Log.i(TAG, "playout stopped (frames=$frameIdx)")
|
||||
}
|
||||
}
|
||||
}
|
||||
142
android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt
Normal file
142
android/app/src/main/java/com/wzp/audio/AudioRouteManager.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
package com.wzp.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
|
||||
*
|
||||
* Wraps [AudioManager] operations and listens for device connection changes
|
||||
* via [AudioDeviceCallback] (API 23+).
|
||||
*
|
||||
* Usage:
|
||||
* 1. Call [register] when the call starts
|
||||
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
|
||||
* 3. Call [unregister] when the call ends
|
||||
*/
|
||||
class AudioRouteManager(context: Context) {
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** Listener for audio route changes. */
|
||||
var onRouteChanged: ((AudioRoute) -> Unit)? = null
|
||||
|
||||
/** Current active route. */
|
||||
var currentRoute: AudioRoute = AudioRoute.EARPIECE
|
||||
private set
|
||||
|
||||
// -- Device callback (API 23+) -------------------------------------------
|
||||
|
||||
private val deviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
for (device in addedDevices) {
|
||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||
// A Bluetooth headset was connected — optionally auto-switch
|
||||
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
for (device in removedDevices) {
|
||||
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
|
||||
// Bluetooth disconnected — fall back to earpiece or speaker
|
||||
val fallback = if (audioManager.isSpeakerphoneOn) {
|
||||
AudioRoute.SPEAKER
|
||||
} else {
|
||||
AudioRoute.EARPIECE
|
||||
}
|
||||
currentRoute = fallback
|
||||
onRouteChanged?.invoke(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/** Register the device callback. Call when a call starts. */
|
||||
fun register() {
|
||||
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
|
||||
}
|
||||
|
||||
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
|
||||
fun unregister() {
|
||||
audioManager.unregisterAudioDeviceCallback(deviceCallback)
|
||||
stopBluetoothSco()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the loudspeaker.
|
||||
*
|
||||
* When enabling speaker, Bluetooth SCO is disconnected.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun setSpeaker(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
stopBluetoothSco()
|
||||
}
|
||||
audioManager.isSpeakerphoneOn = enabled
|
||||
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
|
||||
onRouteChanged?.invoke(currentRoute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
|
||||
*
|
||||
* When enabling Bluetooth, the speaker is turned off.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun setBluetoothSco(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
audioManager.isSpeakerphoneOn = false
|
||||
audioManager.startBluetoothSco()
|
||||
audioManager.isBluetoothScoOn = true
|
||||
currentRoute = AudioRoute.BLUETOOTH
|
||||
} else {
|
||||
stopBluetoothSco()
|
||||
currentRoute = AudioRoute.EARPIECE
|
||||
}
|
||||
onRouteChanged?.invoke(currentRoute)
|
||||
}
|
||||
|
||||
/** Check whether a Bluetooth SCO device is currently connected. */
|
||||
fun isBluetoothAvailable(): Boolean {
|
||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||
}
|
||||
|
||||
/** List available output audio routes. */
|
||||
fun availableRoutes(): List<AudioRoute> {
|
||||
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
|
||||
if (isBluetoothAvailable()) {
|
||||
routes.add(AudioRoute.BLUETOOTH)
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// -- Internal -------------------------------------------------------------
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun stopBluetoothSco() {
|
||||
if (audioManager.isBluetoothScoOn) {
|
||||
audioManager.isBluetoothScoOn = false
|
||||
audioManager.stopBluetoothSco()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio output route. */
|
||||
enum class AudioRoute {
|
||||
/** Phone earpiece (default for calls). */
|
||||
EARPIECE,
|
||||
/** Built-in loudspeaker. */
|
||||
SPEAKER,
|
||||
/** Bluetooth SCO headset/headphones. */
|
||||
BLUETOOTH
|
||||
}
|
||||
203
android/app/src/main/java/com/wzp/data/SettingsRepository.kt
Normal file
203
android/app/src/main/java/com/wzp/data/SettingsRepository.kt
Normal file
@@ -0,0 +1,203 @@
|
||||
package com.wzp.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.wzp.ui.call.ServerEntry
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Persists user settings via SharedPreferences.
|
||||
*
|
||||
* Stores: servers, default server index, room name, alias, gain values,
|
||||
* IPv6 preference, and the identity seed (hex-encoded 32 bytes).
|
||||
*/
|
||||
class SettingsRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences("wzp_settings", Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
private const val KEY_SERVERS = "servers_json"
|
||||
private const val KEY_SELECTED_SERVER = "selected_server"
|
||||
private const val KEY_ROOM = "room_name"
|
||||
private const val KEY_ALIAS = "alias"
|
||||
private const val KEY_PLAYOUT_GAIN = "playout_gain_db"
|
||||
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
|
||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||
private const val TOFU_PREFIX = "tofu_"
|
||||
}
|
||||
|
||||
// --- Servers ---
|
||||
|
||||
fun saveServers(servers: List<ServerEntry>) {
|
||||
val arr = JSONArray()
|
||||
servers.forEach { entry ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("address", entry.address)
|
||||
put("label", entry.label)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(KEY_SERVERS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadServers(): List<ServerEntry>? {
|
||||
val json = prefs.getString(KEY_SERVERS, null) ?: return null
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
ServerEntry(obj.getString("address"), obj.getString("label"))
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveSelectedServer(index: Int) {
|
||||
prefs.edit().putInt(KEY_SELECTED_SERVER, index).apply()
|
||||
}
|
||||
|
||||
fun loadSelectedServer(): Int = prefs.getInt(KEY_SELECTED_SERVER, 0)
|
||||
|
||||
// --- Room ---
|
||||
|
||||
fun saveRoom(name: String) { prefs.edit().putString(KEY_ROOM, name).apply() }
|
||||
fun loadRoom(): String = prefs.getString(KEY_ROOM, "android") ?: "android"
|
||||
|
||||
// --- Alias ---
|
||||
|
||||
fun saveAlias(alias: String) { prefs.edit().putString(KEY_ALIAS, alias).apply() }
|
||||
|
||||
/**
|
||||
* Load alias, generating a random name on first launch.
|
||||
*/
|
||||
fun getOrCreateAlias(): String {
|
||||
val existing = prefs.getString(KEY_ALIAS, null)
|
||||
if (!existing.isNullOrEmpty()) return existing
|
||||
val name = generateRandomName()
|
||||
prefs.edit().putString(KEY_ALIAS, name).apply()
|
||||
return name
|
||||
}
|
||||
|
||||
private fun generateRandomName(): String {
|
||||
val adjectives = listOf(
|
||||
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
|
||||
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
|
||||
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
|
||||
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
|
||||
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc"
|
||||
)
|
||||
val nouns = listOf(
|
||||
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
|
||||
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
|
||||
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
|
||||
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
|
||||
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus"
|
||||
)
|
||||
val adj = adjectives.random()
|
||||
val noun = nouns.random()
|
||||
return "$adj $noun"
|
||||
}
|
||||
|
||||
// --- Gain ---
|
||||
|
||||
fun savePlayoutGain(db: Float) { prefs.edit().putFloat(KEY_PLAYOUT_GAIN, db).apply() }
|
||||
fun loadPlayoutGain(): Float = prefs.getFloat(KEY_PLAYOUT_GAIN, 0f)
|
||||
|
||||
fun saveCaptureGain(db: Float) { prefs.edit().putFloat(KEY_CAPTURE_GAIN, db).apply() }
|
||||
fun loadCaptureGain(): Float = prefs.getFloat(KEY_CAPTURE_GAIN, 0f)
|
||||
|
||||
// --- IPv6 ---
|
||||
|
||||
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
|
||||
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
|
||||
|
||||
// --- AEC ---
|
||||
|
||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||
|
||||
// --- Debug recording ---
|
||||
|
||||
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
||||
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
||||
|
||||
// --- Codec choice ---
|
||||
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
||||
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
||||
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
||||
|
||||
// --- Identity seed ---
|
||||
|
||||
/**
|
||||
* Get or generate the identity seed. On first call, generates a random
|
||||
* 32-byte seed and persists it. Subsequent calls return the same seed.
|
||||
*/
|
||||
fun getOrCreateSeedHex(): String {
|
||||
val existing = prefs.getString(KEY_IDENTITY_SEED, null)
|
||||
if (!existing.isNullOrEmpty()) return existing
|
||||
val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val hex = seed.joinToString("") { "%02x".format(it) }
|
||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||
return hex
|
||||
}
|
||||
|
||||
fun loadSeedHex(): String = prefs.getString(KEY_IDENTITY_SEED, "") ?: ""
|
||||
|
||||
fun saveSeedHex(hex: String) {
|
||||
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
|
||||
}
|
||||
|
||||
// --- Recent rooms ---
|
||||
|
||||
data class RecentRoom(val relay: String, val room: String)
|
||||
|
||||
fun addRecentRoom(relay: String, room: String) {
|
||||
val rooms = loadRecentRooms().toMutableList()
|
||||
rooms.removeAll { it.relay == relay && it.room == room }
|
||||
rooms.add(0, RecentRoom(relay, room))
|
||||
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
|
||||
val arr = JSONArray()
|
||||
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
|
||||
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun loadRecentRooms(): List<RecentRoom> {
|
||||
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
RecentRoom(o.getString("relay"), o.getString("room"))
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun clearRecentRooms() {
|
||||
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
|
||||
}
|
||||
|
||||
// --- Server fingerprint TOFU ---
|
||||
|
||||
fun saveServerFingerprint(address: String, fingerprint: String) {
|
||||
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
|
||||
}
|
||||
|
||||
fun loadServerFingerprint(address: String): String? {
|
||||
return prefs.getString("$TOFU_PREFIX$address", null)
|
||||
}
|
||||
|
||||
// --- Ping RTT cache ---
|
||||
|
||||
fun savePingRtt(address: String, rttMs: Int) {
|
||||
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
|
||||
}
|
||||
|
||||
fun loadPingRtt(address: String): Int {
|
||||
return prefs.getInt("ping_rtt_$address", -1)
|
||||
}
|
||||
}
|
||||
198
android/app/src/main/java/com/wzp/debug/DebugReporter.kt
Normal file
198
android/app/src/main/java/com/wzp/debug/DebugReporter.kt
Normal file
@@ -0,0 +1,198 @@
|
||||
package com.wzp.debug
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Collects call debug data (audio recordings, logs, histograms, stats)
|
||||
* into a zip file for email sharing.
|
||||
*/
|
||||
class DebugReporter(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DebugReporter"
|
||||
private const val SAMPLE_RATE = 48000
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip with all debug data.
|
||||
* Returns the zip File on success, or null on failure.
|
||||
*/
|
||||
suspend fun collectZip(
|
||||
callDurationSecs: Double,
|
||||
finalStatsJson: String,
|
||||
aecEnabled: Boolean,
|
||||
alias: String,
|
||||
server: String,
|
||||
room: String
|
||||
): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
// 1. Call metadata
|
||||
val meta = buildString {
|
||||
appendLine("=== WZ Phone Debug Report ===")
|
||||
appendLine("Timestamp: $timestamp")
|
||||
appendLine("Alias: $alias")
|
||||
appendLine("Server: $server")
|
||||
appendLine("Room: $room")
|
||||
appendLine("Duration: ${"%.1f".format(callDurationSecs)}s")
|
||||
appendLine("AEC: ${if (aecEnabled) "ON" else "OFF"}")
|
||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||
appendLine()
|
||||
appendLine("=== Final Stats ===")
|
||||
appendLine(finalStatsJson)
|
||||
}
|
||||
addTextEntry(zos, "meta.txt", meta)
|
||||
|
||||
// 2. Logcat — WZP-related tags
|
||||
val logcat = collectLogcat()
|
||||
addTextEntry(zos, "logcat.txt", logcat)
|
||||
|
||||
// 3. Capture audio (mic) → WAV
|
||||
val captureRaw = File(debugDir, "capture.pcm")
|
||||
if (captureRaw.exists() && captureRaw.length() > 0) {
|
||||
addWavEntry(zos, "capture.wav", captureRaw)
|
||||
Log.i(TAG, "capture.pcm: ${captureRaw.length()} bytes -> WAV")
|
||||
}
|
||||
|
||||
// 4. Playout audio (speaker) → WAV
|
||||
val playoutRaw = File(debugDir, "playout.pcm")
|
||||
if (playoutRaw.exists() && playoutRaw.length() > 0) {
|
||||
addWavEntry(zos, "playout.wav", playoutRaw)
|
||||
Log.i(TAG, "playout.pcm: ${playoutRaw.length()} bytes -> WAV")
|
||||
}
|
||||
|
||||
// 5. RMS histogram CSV
|
||||
val captureHist = File(debugDir, "capture_rms.csv")
|
||||
if (captureHist.exists()) addFileEntry(zos, "capture_rms.csv", captureHist)
|
||||
val playoutHist = File(debugDir, "playout_rms.csv")
|
||||
if (playoutHist.exists()) addFileEntry(zos, "playout_rms.csv", playoutHist)
|
||||
}
|
||||
|
||||
Log.i(TAG, "zip created: ${zipFile.length()} bytes (${zipFile.length() / 1024}KB)")
|
||||
|
||||
// Clean up raw debug files (keep zip)
|
||||
debugDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
zipFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "debug report failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up any leftover debug files from a previous session. */
|
||||
fun prepareForCall() {
|
||||
val debugDir = File(context.cacheDir, "wzp_debug")
|
||||
if (debugDir.exists()) {
|
||||
debugDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
debugDir.mkdirs()
|
||||
// Also clean up old zip files
|
||||
context.cacheDir.listFiles()?.filter { it.name.startsWith("wzp_debug_") }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun collectLogcat(): String {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(
|
||||
arrayOf(
|
||||
"logcat", "-d",
|
||||
"-t", "5000",
|
||||
"--format", "threadtime"
|
||||
)
|
||||
)
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output.lines()
|
||||
.filter { line ->
|
||||
line.contains("wzp", ignoreCase = true) ||
|
||||
line.contains("WzpEngine") ||
|
||||
line.contains("AudioPipeline") ||
|
||||
line.contains("WzpCall") ||
|
||||
line.contains("CallService") ||
|
||||
line.contains("AudioTrack") ||
|
||||
line.contains("AudioRecord") ||
|
||||
line.contains("AcousticEchoCanceler") ||
|
||||
line.contains("NoiseSuppressor") ||
|
||||
line.contains("FATAL") ||
|
||||
line.contains("ANR") ||
|
||||
line.contains("AudioFlinger") ||
|
||||
line.contains("DebugReporter") ||
|
||||
line.contains("QUIC") ||
|
||||
line.contains("quinn") ||
|
||||
line.contains("send task") ||
|
||||
line.contains("recv task") ||
|
||||
line.contains("send stats") ||
|
||||
line.contains("recv stats") ||
|
||||
line.contains("send_media") ||
|
||||
line.contains("FEC block") ||
|
||||
line.contains("recv gap") ||
|
||||
line.contains("frames_dropped") ||
|
||||
line.contains("opus")
|
||||
}
|
||||
.joinToString("\n")
|
||||
} catch (e: Exception) {
|
||||
"Failed to collect logcat: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun addWavEntry(zos: ZipOutputStream, name: String, pcmFile: File) {
|
||||
val dataSize = pcmFile.length().toInt()
|
||||
val byteRate = SAMPLE_RATE * 1 * 16 / 8
|
||||
val blockAlign = 1 * 16 / 8
|
||||
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
|
||||
// Write WAV header (44 bytes)
|
||||
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
|
||||
header.put("RIFF".toByteArray())
|
||||
header.putInt(36 + dataSize)
|
||||
header.put("WAVE".toByteArray())
|
||||
header.put("fmt ".toByteArray())
|
||||
header.putInt(16)
|
||||
header.putShort(1) // PCM
|
||||
header.putShort(1) // mono
|
||||
header.putInt(SAMPLE_RATE)
|
||||
header.putInt(byteRate)
|
||||
header.putShort(blockAlign.toShort())
|
||||
header.putShort(16) // bits per sample
|
||||
header.put("data".toByteArray())
|
||||
header.putInt(dataSize)
|
||||
zos.write(header.array())
|
||||
|
||||
// Stream PCM data directly (avoids loading entire file into memory)
|
||||
FileInputStream(pcmFile).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
private fun addTextEntry(zos: ZipOutputStream, name: String, content: String) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
zos.write(content.toByteArray())
|
||||
zos.closeEntry()
|
||||
}
|
||||
|
||||
private fun addFileEntry(zos: ZipOutputStream, name: String, file: File) {
|
||||
zos.putNextEntry(ZipEntry(name))
|
||||
FileInputStream(file).use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
97
android/app/src/main/java/com/wzp/engine/CallStats.kt
Normal file
97
android/app/src/main/java/com/wzp/engine/CallStats.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.wzp.engine
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
|
||||
*
|
||||
* Constructed from the JSON string returned by [WzpEngine.getStats].
|
||||
*/
|
||||
data class CallStats(
|
||||
/** Current call state ordinal (see [CallStateConstants]). */
|
||||
val state: Int = 0,
|
||||
/** Call duration in seconds. */
|
||||
val durationSecs: Double = 0.0,
|
||||
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
|
||||
val qualityTier: Int = 0,
|
||||
/** Observed packet loss percentage (0..100). */
|
||||
val lossPct: Float = 0f,
|
||||
/** Smoothed round-trip time in milliseconds. */
|
||||
val rttMs: Int = 0,
|
||||
/** Jitter in milliseconds. */
|
||||
val jitterMs: Int = 0,
|
||||
/** Current jitter buffer depth in packets. */
|
||||
val jitterBufferDepth: Int = 0,
|
||||
/** Total frames encoded since call start. */
|
||||
val framesEncoded: Long = 0,
|
||||
/** Total frames decoded since call start. */
|
||||
val framesDecoded: Long = 0,
|
||||
/** Number of playout underruns (buffer empty when audio was needed). */
|
||||
val underruns: Long = 0,
|
||||
/** Frames recovered by FEC. */
|
||||
val fecRecovered: Long = 0,
|
||||
/** Current mic audio level (RMS, 0-32767). */
|
||||
val audioLevel: Int = 0,
|
||||
/** Number of participants in the room. */
|
||||
val roomParticipantCount: Int = 0,
|
||||
/** Participants in the room (fingerprint + optional alias). */
|
||||
val roomParticipants: List<RoomMember> = emptyList(),
|
||||
) {
|
||||
/** Human-readable quality label. */
|
||||
val qualityLabel: String
|
||||
get() = when (qualityTier) {
|
||||
0 -> "Good"
|
||||
1 -> "Degraded"
|
||||
2 -> "Catastrophic"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun parseParticipants(arr: JSONArray?): List<RoomMember> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
RoomMember(
|
||||
fingerprint = o.optString("fingerprint", ""),
|
||||
alias = if (o.isNull("alias")) null else o.optString("alias", null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deserialise from the JSON string produced by the native engine. */
|
||||
fun fromJson(json: String): CallStats {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
CallStats(
|
||||
state = obj.optInt("state", 0),
|
||||
durationSecs = obj.optDouble("duration_secs", 0.0),
|
||||
qualityTier = obj.optInt("quality_tier", 0),
|
||||
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
|
||||
rttMs = obj.optInt("rtt_ms", 0),
|
||||
jitterMs = obj.optInt("jitter_ms", 0),
|
||||
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
|
||||
framesEncoded = obj.optLong("frames_encoded", 0),
|
||||
framesDecoded = obj.optLong("frames_decoded", 0),
|
||||
underruns = obj.optLong("underruns", 0),
|
||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
||||
audioLevel = obj.optInt("audio_level", 0),
|
||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
CallStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RoomMember(
|
||||
val fingerprint: String,
|
||||
val alias: String? = null
|
||||
) {
|
||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
||||
val displayName: String
|
||||
get() = alias?.takeIf { it.isNotBlank() }
|
||||
?: fingerprint.take(8).ifEmpty { "unknown" }
|
||||
}
|
||||
32
android/app/src/main/java/com/wzp/engine/WzpCallback.kt
Normal file
32
android/app/src/main/java/com/wzp/engine/WzpCallback.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.wzp.engine
|
||||
|
||||
/**
|
||||
* Callback interface for VoIP engine events.
|
||||
*
|
||||
* All callbacks are invoked on the main/UI thread.
|
||||
*/
|
||||
interface WzpCallback {
|
||||
|
||||
/**
|
||||
* Called when the call state changes.
|
||||
*
|
||||
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
|
||||
* RECONNECTING(3), CLOSED(4)
|
||||
*/
|
||||
fun onCallStateChanged(state: Int)
|
||||
|
||||
/**
|
||||
* Called when the network quality tier changes.
|
||||
*
|
||||
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
|
||||
*/
|
||||
fun onQualityTierChanged(tier: Int)
|
||||
|
||||
/**
|
||||
* Called when an error occurs in the native engine.
|
||||
*
|
||||
* @param code numeric error code (negative)
|
||||
* @param message human-readable description
|
||||
*/
|
||||
fun onError(code: Int, message: String)
|
||||
}
|
||||
184
android/app/src/main/java/com/wzp/engine/WzpEngine.kt
Normal file
184
android/app/src/main/java/com/wzp/engine/WzpEngine.kt
Normal file
@@ -0,0 +1,184 @@
|
||||
package com.wzp.engine
|
||||
|
||||
/**
|
||||
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. Construct with a [WzpCallback]
|
||||
* 2. Call [init] to create the native engine
|
||||
* 3. Call [startCall] to begin a VoIP session
|
||||
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
|
||||
* 5. Call [stopCall] to end the session
|
||||
* 6. Call [destroy] when the engine is no longer needed
|
||||
*
|
||||
* Thread safety: all methods must be called from the same thread (typically main).
|
||||
*/
|
||||
class WzpEngine(private val callback: WzpCallback) {
|
||||
|
||||
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
|
||||
private var nativeHandle: Long = 0L
|
||||
|
||||
/** Whether the engine has been initialised. */
|
||||
val isInitialized: Boolean get() = nativeHandle != 0L
|
||||
|
||||
/** Create the native engine. Must be called before any other method. */
|
||||
fun init() {
|
||||
check(nativeHandle == 0L) { "Engine already initialized" }
|
||||
nativeHandle = nativeInit()
|
||||
check(nativeHandle != 0L) { "Native engine creation failed" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a call.
|
||||
*
|
||||
* @param relayAddr relay server address (host:port)
|
||||
* @param room room identifier (used as QUIC SNI)
|
||||
* @param seedHex 64-char hex-encoded 32-byte identity seed (empty = random)
|
||||
* @param token authentication token (empty = no auth)
|
||||
* @param alias display name sent to relay for room participant list
|
||||
* @return 0 on success, negative error code on failure
|
||||
*/
|
||||
/**
|
||||
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
|
||||
*/
|
||||
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
|
||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
||||
if (result == 0) {
|
||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||
} else {
|
||||
callback.onError(result, "Failed to start call")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Stop the active call. Safe to call when no call is active. */
|
||||
fun stopCall() {
|
||||
if (nativeHandle != 0L) {
|
||||
nativeStopCall(nativeHandle)
|
||||
callback.onCallStateChanged(CallStateConstants.CLOSED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mute or unmute the microphone. */
|
||||
fun setMute(muted: Boolean) {
|
||||
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
|
||||
}
|
||||
|
||||
/** Enable or disable loudspeaker mode. */
|
||||
fun setSpeaker(speaker: Boolean) {
|
||||
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get current call statistics as a JSON string.
|
||||
*
|
||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||
*/
|
||||
fun getStats(): String {
|
||||
if (nativeHandle == 0L) return "{}"
|
||||
return try {
|
||||
nativeGetStats(nativeHandle) ?: "{}"
|
||||
} catch (_: Exception) {
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a quality profile, overriding adaptive selection.
|
||||
*
|
||||
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
|
||||
*/
|
||||
fun forceProfile(profile: Int) {
|
||||
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
|
||||
}
|
||||
|
||||
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||
fun destroy() {
|
||||
if (nativeHandle != 0L) {
|
||||
nativeDestroy(nativeHandle)
|
||||
nativeHandle = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write captured PCM samples into the engine's capture ring buffer.
|
||||
* Called from the AudioRecord capture thread.
|
||||
*/
|
||||
fun writeAudio(pcm: ShortArray): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeWriteAudio(nativeHandle, pcm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read decoded PCM samples from the engine's playout ring buffer.
|
||||
* Called from the AudioTrack playout thread.
|
||||
*/
|
||||
fun readAudio(pcm: ShortArray): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeReadAudio(nativeHandle, pcm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
|
||||
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
|
||||
* Called from the AudioRecord capture thread.
|
||||
*/
|
||||
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
|
||||
* The buffer must be a direct ByteBuffer with native byte order.
|
||||
* Called from the AudioTrack playout thread.
|
||||
*/
|
||||
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
|
||||
if (nativeHandle == 0L) return 0
|
||||
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
|
||||
}
|
||||
|
||||
// -- JNI native methods --------------------------------------------------
|
||||
|
||||
private external fun nativeInit(): Long
|
||||
private external fun nativeStartCall(
|
||||
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
|
||||
): Int
|
||||
private external fun nativeStopCall(handle: Long)
|
||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
|
||||
private external fun nativeGetStats(handle: Long): String?
|
||||
private external fun nativeForceProfile(handle: Long, profile: Int)
|
||||
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
|
||||
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
|
||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
|
||||
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
private external fun nativePingRelay(handle: Long, relay: String): String?
|
||||
|
||||
/**
|
||||
* Ping a relay server. Requires engine to be initialized.
|
||||
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
||||
*/
|
||||
fun pingRelay(address: String): String? {
|
||||
if (nativeHandle == 0L) return null
|
||||
return nativePingRelay(nativeHandle, address)
|
||||
}
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("wzp_android")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
||||
object CallStateConstants {
|
||||
const val IDLE = 0
|
||||
const val CONNECTING = 1
|
||||
const val ACTIVE = 2
|
||||
const val RECONNECTING = 3
|
||||
const val CLOSED = 4
|
||||
}
|
||||
12
android/app/src/main/java/com/wzp/net/RelayPinger.kt
Normal file
12
android/app/src/main/java/com/wzp/net/RelayPinger.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.wzp.net
|
||||
|
||||
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
|
||||
// This file kept for the data class only.
|
||||
|
||||
object RelayPinger {
|
||||
data class PingResult(
|
||||
val rttMs: Int,
|
||||
val reachable: Boolean,
|
||||
val serverFingerprint: String = "",
|
||||
)
|
||||
}
|
||||
172
android/app/src/main/java/com/wzp/service/CallService.kt
Normal file
172
android/app/src/main/java/com/wzp/service/CallService.kt
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.wzp.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.wzp.WzpApplication
|
||||
import com.wzp.ui.call.CallActivity
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Shows a persistent notification during the call
|
||||
* - Acquires a partial wake lock so the CPU stays on
|
||||
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
|
||||
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
|
||||
* - Releases all resources when the call ends
|
||||
*/
|
||||
class CallService : Service() {
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
|
||||
|
||||
// -- Lifecycle ------------------------------------------------------------
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
acquireWakeLock()
|
||||
acquireWifiLock()
|
||||
setAudioMode()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
onStopFromNotification?.invoke()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
restoreAudioMode()
|
||||
releaseWifiLock()
|
||||
releaseWakeLock()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
// -- Notification ---------------------------------------------------------
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
// Tapping the notification returns to the call screen
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, CallActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
// "End call" action button
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
|
||||
.setContentTitle("WZ Phone")
|
||||
.setContentText("Call in progress")
|
||||
.setSmallIcon(android.R.drawable.ic_menu_call)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
// -- Wake lock ------------------------------------------------------------
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"wzp:call_wake_lock"
|
||||
).apply {
|
||||
acquire(MAX_CALL_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) it.release()
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
// -- Wi-Fi lock -----------------------------------------------------------
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireWifiLock() {
|
||||
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
wifiLock = wm.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
"wzp:call_wifi_lock"
|
||||
).apply {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWifiLock() {
|
||||
wifiLock?.let {
|
||||
if (it.isHeld) it.release()
|
||||
}
|
||||
wifiLock = null
|
||||
}
|
||||
|
||||
// -- Audio mode -----------------------------------------------------------
|
||||
|
||||
private fun setAudioMode() {
|
||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
previousAudioMode = am.mode
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
}
|
||||
|
||||
private fun restoreAudioMode() {
|
||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.mode = previousAudioMode
|
||||
}
|
||||
|
||||
// -- Static helpers -------------------------------------------------------
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val ACTION_STOP = "com.wzp.service.STOP"
|
||||
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
|
||||
|
||||
/** Called when the user taps "End Call" in the notification. */
|
||||
var onStopFromNotification: (() -> Unit)? = null
|
||||
|
||||
/** Start the foreground call service. */
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, CallService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/** Stop the foreground call service. */
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, CallService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
149
android/app/src/main/java/com/wzp/ui/call/CallActivity.kt
Normal file
@@ -0,0 +1,149 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.wzp.ui.settings.SettingsScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Main activity hosting the in-call Compose UI.
|
||||
*
|
||||
* Call lifecycle (wake lock, Wi-Fi lock, audio mode, notification)
|
||||
* is managed by [com.wzp.service.CallService] foreground service.
|
||||
*/
|
||||
class CallActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CallActivity"
|
||||
}
|
||||
|
||||
private val viewModel: CallViewModel by viewModels()
|
||||
|
||||
private val audioPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (!granted) {
|
||||
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.setContext(this)
|
||||
|
||||
setContent {
|
||||
WzpTheme {
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { showSettings = false }
|
||||
)
|
||||
} else {
|
||||
InCallScreen(
|
||||
viewModel = viewModel,
|
||||
onHangUp = { viewModel.stopCall() },
|
||||
onOpenSettings = { showSettings = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// Watch for debug zip ready → launch email intent
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.debugZipReady.collect { zipFile ->
|
||||
if (zipFile != null && zipFile.exists()) {
|
||||
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
|
||||
launchEmailIntent(zipFile)
|
||||
viewModel.onDebugReportSent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchEmailIntent(zipFile: java.io.File) {
|
||||
try {
|
||||
val authority = "${applicationContext.packageName}.fileprovider"
|
||||
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
|
||||
val uri = FileProvider.getUriForFile(this, authority, zipFile)
|
||||
Log.i(TAG, "FileProvider URI: $uri")
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "message/rfc822"
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
|
||||
)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Send debug report"))
|
||||
Log.i(TAG, "email intent launched")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "email intent failed", e)
|
||||
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isFinishing) {
|
||||
viewModel.stopCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WzpTheme(content: @Composable () -> Unit) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme = when {
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
594
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
594
android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt
Normal file
@@ -0,0 +1,594 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.audio.AudioPipeline
|
||||
import com.wzp.audio.AudioRouteManager
|
||||
import com.wzp.data.SettingsRepository
|
||||
import com.wzp.debug.DebugReporter
|
||||
import com.wzp.engine.CallStats
|
||||
import com.wzp.service.CallService
|
||||
import com.wzp.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
|
||||
data class ServerEntry(val address: String, val label: String)
|
||||
|
||||
data class PingResult(
|
||||
val rttMs: Int,
|
||||
val serverFingerprint: String = "",
|
||||
val reachable: Boolean = rttMs > 0,
|
||||
)
|
||||
|
||||
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||
|
||||
class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
private var engine: WzpEngine? = null
|
||||
private var engineInitialized = false
|
||||
private var audioPipeline: AudioPipeline? = null
|
||||
private var audioRouteManager: AudioRouteManager? = null
|
||||
private var audioStarted = false
|
||||
private var appContext: Context? = null
|
||||
private var settings: SettingsRepository? = null
|
||||
private var debugReporter: DebugReporter? = null
|
||||
private var lastStatsJson: String = "{}"
|
||||
private var lastCallDuration: Double = 0.0
|
||||
private var lastCallServer: String = ""
|
||||
|
||||
private val _callState = MutableStateFlow(0)
|
||||
val callState: StateFlow<Int> get() = _callState.asStateFlow()
|
||||
|
||||
private val _isMuted = MutableStateFlow(false)
|
||||
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
|
||||
|
||||
private val _isSpeaker = MutableStateFlow(false)
|
||||
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
|
||||
|
||||
private val _stats = MutableStateFlow(CallStats())
|
||||
val stats: StateFlow<CallStats> = _stats.asStateFlow()
|
||||
|
||||
private val _qualityTier = MutableStateFlow(0)
|
||||
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
|
||||
val roomName: StateFlow<String> = _roomName.asStateFlow()
|
||||
|
||||
private val _selectedServer = MutableStateFlow(0)
|
||||
val selectedServer: StateFlow<Int> = _selectedServer.asStateFlow()
|
||||
|
||||
private val _servers = MutableStateFlow(DEFAULT_SERVERS.toList())
|
||||
val servers: StateFlow<List<ServerEntry>> = _servers.asStateFlow()
|
||||
|
||||
private val _preferIPv6 = MutableStateFlow(false)
|
||||
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
|
||||
|
||||
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
|
||||
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
|
||||
|
||||
/** Ping results keyed by server address. */
|
||||
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
|
||||
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
|
||||
|
||||
/** Known server fingerprints (TOFU). */
|
||||
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
|
||||
private val _playoutGainDb = MutableStateFlow(0f)
|
||||
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
|
||||
|
||||
private val _captureGainDb = MutableStateFlow(0f)
|
||||
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
|
||||
|
||||
private val _alias = MutableStateFlow("")
|
||||
val alias: StateFlow<String> = _alias.asStateFlow()
|
||||
|
||||
private val _seedHex = MutableStateFlow("")
|
||||
val seedHex: StateFlow<String> = _seedHex.asStateFlow()
|
||||
|
||||
private val _aecEnabled = MutableStateFlow(true)
|
||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||
|
||||
private val _debugRecording = MutableStateFlow(false)
|
||||
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
|
||||
|
||||
// Quality profile index (matches JNI bridge profile_from_int)
|
||||
private val _codecChoice = MutableStateFlow(0)
|
||||
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
|
||||
|
||||
/** Key-change warning dialog state. */
|
||||
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
|
||||
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
|
||||
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
|
||||
|
||||
/** True when a call just ended and debug report can be sent. */
|
||||
private val _debugReportAvailable = MutableStateFlow(false)
|
||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||
|
||||
/** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
|
||||
private val _debugReportStatus = MutableStateFlow<String?>(null)
|
||||
val debugReportStatus: StateFlow<String?> = _debugReportStatus.asStateFlow()
|
||||
|
||||
/** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
|
||||
private val _debugZipReady = MutableStateFlow<File?>(null)
|
||||
val debugZipReady: StateFlow<File?> = _debugZipReady.asStateFlow()
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WzpCall"
|
||||
val DEFAULT_SERVERS = listOf(
|
||||
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||
)
|
||||
const val DEFAULT_ROOM = "android"
|
||||
}
|
||||
|
||||
fun setContext(context: Context) {
|
||||
val appCtx = context.applicationContext
|
||||
appContext = appCtx
|
||||
if (audioPipeline == null) {
|
||||
audioPipeline = AudioPipeline(appCtx)
|
||||
}
|
||||
if (audioRouteManager == null) {
|
||||
audioRouteManager = AudioRouteManager(appCtx)
|
||||
}
|
||||
if (debugReporter == null) {
|
||||
debugReporter = DebugReporter(appCtx)
|
||||
}
|
||||
if (settings == null) {
|
||||
settings = SettingsRepository(appCtx)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val s = settings ?: return
|
||||
s.loadServers()?.let { saved ->
|
||||
if (saved.isNotEmpty()) _servers.value = saved
|
||||
}
|
||||
_selectedServer.value = s.loadSelectedServer().coerceIn(0, _servers.value.lastIndex)
|
||||
_roomName.value = s.loadRoom()
|
||||
_alias.value = s.getOrCreateAlias()
|
||||
_preferIPv6.value = s.loadPreferIPv6()
|
||||
_playoutGainDb.value = s.loadPlayoutGain()
|
||||
_captureGainDb.value = s.loadCaptureGain()
|
||||
_seedHex.value = s.getOrCreateSeedHex()
|
||||
_aecEnabled.value = s.loadAecEnabled()
|
||||
_debugRecording.value = s.loadDebugRecording()
|
||||
_codecChoice.value = s.loadCodecChoice()
|
||||
_recentRooms.value = s.loadRecentRooms()
|
||||
}
|
||||
|
||||
fun selectServer(index: Int) {
|
||||
if (index in _servers.value.indices) {
|
||||
_selectedServer.value = index
|
||||
settings?.saveSelectedServer(index)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPreferIPv6(prefer: Boolean) {
|
||||
_preferIPv6.value = prefer
|
||||
settings?.savePreferIPv6(prefer)
|
||||
}
|
||||
|
||||
fun addServer(hostPort: String, label: String) {
|
||||
val current = _servers.value.toMutableList()
|
||||
current.add(ServerEntry(hostPort, label))
|
||||
_servers.value = current
|
||||
settings?.saveServers(current)
|
||||
}
|
||||
|
||||
fun removeServer(index: Int) {
|
||||
if (index < DEFAULT_SERVERS.size) return // don't remove built-in servers
|
||||
val current = _servers.value.toMutableList()
|
||||
if (index in current.indices) {
|
||||
current.removeAt(index)
|
||||
_servers.value = current
|
||||
if (_selectedServer.value >= current.size) {
|
||||
_selectedServer.value = 0
|
||||
}
|
||||
settings?.saveServers(current)
|
||||
settings?.saveSelectedServer(_selectedServer.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Batch-apply servers and selection from Settings draft state. */
|
||||
fun applyServers(servers: List<ServerEntry>, selected: Int) {
|
||||
_servers.value = servers
|
||||
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
|
||||
settings?.saveServers(servers)
|
||||
settings?.saveSelectedServer(_selectedServer.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping all servers via native QUIC. Requires engine to be initialized.
|
||||
* Creates engine if needed, pings, keeps engine alive for subsequent Connect.
|
||||
*/
|
||||
fun pingAllServers() {
|
||||
viewModelScope.launch {
|
||||
// Ensure engine exists
|
||||
if (engine == null || engine?.isInitialized != true) {
|
||||
try {
|
||||
engine = WzpEngine(this@CallViewModel).also { it.init() }
|
||||
engineInitialized = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "engine init for ping failed: $e")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
val eng = engine ?: return@launch
|
||||
|
||||
val results = mutableMapOf<String, PingResult>()
|
||||
val known = mutableMapOf<String, String>()
|
||||
_servers.value.forEach { server ->
|
||||
val json = withContext(Dispatchers.IO) {
|
||||
eng.pingRelay(server.address)
|
||||
}
|
||||
if (json != null) {
|
||||
try {
|
||||
val obj = JSONObject(json)
|
||||
val rtt = obj.getInt("rtt_ms")
|
||||
val fp = obj.optString("server_fingerprint", "")
|
||||
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp)
|
||||
// TOFU
|
||||
if (fp.isNotEmpty()) {
|
||||
val saved = settings?.loadServerFingerprint(server.address)
|
||||
if (saved == null) settings?.saveServerFingerprint(server.address, fp)
|
||||
known[server.address] = saved ?: fp
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
_pingResults.value = results
|
||||
_knownFingerprints.value = known
|
||||
}
|
||||
}
|
||||
|
||||
/** Load saved TOFU fingerprints. */
|
||||
fun loadSavedFingerprints() {
|
||||
val known = mutableMapOf<String, String>()
|
||||
_servers.value.forEach { server ->
|
||||
settings?.loadServerFingerprint(server.address)?.let {
|
||||
known[server.address] = it
|
||||
}
|
||||
}
|
||||
_knownFingerprints.value = known
|
||||
}
|
||||
|
||||
/** Get lock status for a server. */
|
||||
fun lockStatus(address: String): LockStatus {
|
||||
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
||||
if (!pr.reachable) return LockStatus.OFFLINE
|
||||
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
|
||||
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
||||
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||
}
|
||||
|
||||
fun setRoomName(name: String) {
|
||||
_roomName.value = name
|
||||
settings?.saveRoom(name)
|
||||
}
|
||||
|
||||
fun setPlayoutGainDb(db: Float) {
|
||||
_playoutGainDb.value = db
|
||||
audioPipeline?.playoutGainDb = db
|
||||
settings?.savePlayoutGain(db)
|
||||
}
|
||||
|
||||
fun setCaptureGainDb(db: Float) {
|
||||
_captureGainDb.value = db
|
||||
audioPipeline?.captureGainDb = db
|
||||
settings?.saveCaptureGain(db)
|
||||
}
|
||||
|
||||
fun setAlias(alias: String) {
|
||||
_alias.value = alias
|
||||
settings?.saveAlias(alias)
|
||||
}
|
||||
|
||||
fun restoreSeed(hex: String) {
|
||||
_seedHex.value = hex
|
||||
settings?.saveSeedHex(hex)
|
||||
}
|
||||
|
||||
fun setAecEnabled(enabled: Boolean) {
|
||||
_aecEnabled.value = enabled
|
||||
settings?.saveAecEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setDebugRecording(enabled: Boolean) {
|
||||
_debugRecording.value = enabled
|
||||
settings?.saveDebugRecording(enabled)
|
||||
}
|
||||
|
||||
fun setCodecChoice(choice: Int) {
|
||||
_codecChoice.value = choice
|
||||
settings?.saveCodecChoice(choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||
* since Rust's DNS resolution may not work on Android.
|
||||
* Returns "ip:port" string.
|
||||
*/
|
||||
private fun resolveToIp(hostPort: String): String {
|
||||
val parts = hostPort.split(":")
|
||||
if (parts.size != 2) return hostPort
|
||||
val host = parts[0]
|
||||
val port = parts[1]
|
||||
|
||||
// Already an IP address — return as-is
|
||||
if (host.matches(Regex("""\d+\.\d+\.\d+\.\d+"""))) return hostPort
|
||||
if (host.contains(":")) return hostPort // IPv6 literal
|
||||
|
||||
return try {
|
||||
val addresses = InetAddress.getAllByName(host)
|
||||
val preferV6 = _preferIPv6.value
|
||||
val picked = if (preferV6) {
|
||||
addresses.firstOrNull { it is Inet6Address } ?: addresses.firstOrNull { it is Inet4Address }
|
||||
} else {
|
||||
addresses.firstOrNull { it is Inet4Address } ?: addresses.firstOrNull { it is Inet6Address }
|
||||
}
|
||||
if (picked != null) {
|
||||
val ip = picked.hostAddress ?: host
|
||||
val formatted = if (picked is Inet6Address) "[$ip]:$port" else "$ip:$port"
|
||||
formatted
|
||||
} else {
|
||||
hostPort
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
hostPort // resolution failed — pass through and let Rust try
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
|
||||
private fun teardown(stopService: Boolean = true) {
|
||||
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
||||
val hadCall = audioStarted
|
||||
CallService.onStopFromNotification = null
|
||||
stopAudio() // sets running=false (non-blocking)
|
||||
stopStatsPolling()
|
||||
|
||||
// Wait for audio threads to exit their loops before destroying the engine.
|
||||
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
|
||||
val drained = audioPipeline?.awaitDrain() ?: true
|
||||
if (!drained) {
|
||||
Log.w(TAG, "teardown: audio threads did not drain in time")
|
||||
}
|
||||
audioPipeline = null
|
||||
|
||||
Log.i(TAG, "teardown: stopping engine")
|
||||
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
||||
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
_callState.value = 0
|
||||
if (hadCall) {
|
||||
_debugReportAvailable.value = true
|
||||
}
|
||||
if (stopService) {
|
||||
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
|
||||
}
|
||||
Log.i(TAG, "teardown: done")
|
||||
}
|
||||
|
||||
/** Accept the new server key and proceed with the call. */
|
||||
fun acceptNewFingerprint() {
|
||||
val info = _keyWarning.value ?: return
|
||||
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
|
||||
it[info.address] = info.newFp
|
||||
}
|
||||
settings?.saveServerFingerprint(info.address, info.newFp)
|
||||
_keyWarning.value = null
|
||||
startCallInternal()
|
||||
}
|
||||
|
||||
fun dismissKeyWarning() {
|
||||
_keyWarning.value = null
|
||||
}
|
||||
|
||||
fun startCall() {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
// Check for key change before connecting
|
||||
val ls = lockStatus(serverEntry.address)
|
||||
if (ls == LockStatus.CHANGED) {
|
||||
val known = _knownFingerprints.value[serverEntry.address] ?: ""
|
||||
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
|
||||
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
|
||||
return
|
||||
}
|
||||
startCallInternal()
|
||||
}
|
||||
|
||||
private fun startCallInternal() {
|
||||
val serverEntry = _servers.value[_selectedServer.value]
|
||||
val room = _roomName.value
|
||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
lastCallServer = serverEntry.address
|
||||
settings?.addRecentRoom(serverEntry.address, room)
|
||||
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
|
||||
debugReporter?.prepareForCall()
|
||||
try {
|
||||
// Teardown previous call but don't stop the service (we're about to restart it)
|
||||
teardown(stopService = false)
|
||||
|
||||
Log.i(TAG, "startCall: creating engine")
|
||||
engine = WzpEngine(this)
|
||||
engine!!.init()
|
||||
engineInitialized = true
|
||||
_callState.value = 1
|
||||
_errorMessage.value = null
|
||||
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
||||
Log.w(TAG, "service start err: $e")
|
||||
}
|
||||
startStatsPolling()
|
||||
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val relay = resolveToIp(serverEntry.address)
|
||||
val seed = _seedHex.value
|
||||
val name = _alias.value
|
||||
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
||||
Log.i(TAG, "startCall: engine returned $result")
|
||||
// Only wire up notification callback after engine is running
|
||||
CallService.onStopFromNotification = { stopCall() }
|
||||
if (result != 0) {
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Failed to start call (code $result)"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCall IO error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCall error", e)
|
||||
_callState.value = 0
|
||||
_errorMessage.value = "Engine error: ${e.message}"
|
||||
appContext?.let { CallService.stop(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCall() {
|
||||
Log.i(TAG, "stopCall")
|
||||
teardown()
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
val newMuted = !_isMuted.value
|
||||
_isMuted.value = newMuted
|
||||
try { engine?.setMute(newMuted) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun toggleSpeaker() {
|
||||
val newSpeaker = !_isSpeaker.value
|
||||
_isSpeaker.value = newSpeaker
|
||||
audioRouteManager?.setSpeaker(newSpeaker)
|
||||
}
|
||||
|
||||
fun clearError() { _errorMessage.value = null }
|
||||
|
||||
fun sendDebugReport() {
|
||||
val reporter = debugReporter ?: return
|
||||
_debugReportStatus.value = "Preparing debug report..."
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val zipFile = reporter.collectZip(
|
||||
callDurationSecs = lastCallDuration,
|
||||
finalStatsJson = lastStatsJson,
|
||||
aecEnabled = _aecEnabled.value,
|
||||
alias = _alias.value,
|
||||
server = lastCallServer,
|
||||
room = _roomName.value
|
||||
)
|
||||
if (zipFile != null) {
|
||||
_debugZipReady.value = zipFile
|
||||
_debugReportStatus.value = "ready"
|
||||
} else {
|
||||
_debugReportStatus.value = "Error: failed to create zip"
|
||||
}
|
||||
_debugReportAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by Activity after email intent is launched. */
|
||||
fun onDebugReportSent() {
|
||||
_debugZipReady.value = null
|
||||
_debugReportStatus.value = null
|
||||
}
|
||||
|
||||
fun dismissDebugReport() {
|
||||
_debugReportAvailable.value = false
|
||||
_debugReportStatus.value = null
|
||||
_debugZipReady.value = null
|
||||
}
|
||||
|
||||
// WzpCallback
|
||||
override fun onCallStateChanged(state: Int) { _callState.value = state }
|
||||
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
|
||||
override fun onError(code: Int, message: String) { _errorMessage.value = "Error $code: $message" }
|
||||
|
||||
private fun startAudio() {
|
||||
if (audioStarted) return
|
||||
val e = engine ?: return
|
||||
val ctx = appContext ?: return
|
||||
// Create a fresh pipeline each call to avoid stale threads
|
||||
audioPipeline = AudioPipeline(ctx).also {
|
||||
it.playoutGainDb = _playoutGainDb.value
|
||||
it.captureGainDb = _captureGainDb.value
|
||||
it.aecEnabled = _aecEnabled.value
|
||||
it.debugRecording = _debugRecording.value
|
||||
it.start(e)
|
||||
}
|
||||
audioRouteManager?.register()
|
||||
audioStarted = true
|
||||
}
|
||||
|
||||
private fun stopAudio() {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
|
||||
audioRouteManager?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
audioStarted = false
|
||||
}
|
||||
|
||||
private fun startStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
val json = engine?.getStats() ?: "{}"
|
||||
if (json.isNotEmpty()) {
|
||||
Log.d(TAG, "raw: $json")
|
||||
lastStatsJson = json
|
||||
val s = CallStats.fromJson(json)
|
||||
lastCallDuration = s.durationSecs
|
||||
_stats.value = s
|
||||
if (s.state != 0) {
|
||||
_callState.value = s.state
|
||||
}
|
||||
if (s.state == 2 && !audioStarted) {
|
||||
startAudio()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.i(TAG, "onCleared")
|
||||
teardown()
|
||||
}
|
||||
}
|
||||
827
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
827
android/app/src/main/java/com/wzp/ui/call/InCallScreen.kt
Normal file
@@ -0,0 +1,827 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wzp.engine.CallStats
|
||||
import com.wzp.ui.components.CopyableFingerprint
|
||||
import com.wzp.ui.components.Identicon
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Desktop-style dark theme colors
|
||||
private val DarkBg = Color(0xFF0F0F1A)
|
||||
private val DarkSurface = Color(0xFF1A1A2E)
|
||||
private val DarkSurface2 = Color(0xFF222244)
|
||||
private val Accent = Color(0xFFE94560)
|
||||
private val Green = Color(0xFF4ADE80)
|
||||
private val Yellow = Color(0xFFFACC15)
|
||||
private val Red = Color(0xFFEF4444)
|
||||
private val TextDim = Color(0xFF777777)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun InCallScreen(
|
||||
viewModel: CallViewModel,
|
||||
onHangUp: () -> Unit,
|
||||
onOpenSettings: () -> Unit = {}
|
||||
) {
|
||||
val callState by viewModel.callState.collectAsState()
|
||||
val isMuted by viewModel.isMuted.collectAsState()
|
||||
val isSpeaker by viewModel.isSpeaker.collectAsState()
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val qualityTier by viewModel.qualityTier.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val roomName by viewModel.roomName.collectAsState()
|
||||
val selectedServer by viewModel.selectedServer.collectAsState()
|
||||
val servers by viewModel.servers.collectAsState()
|
||||
val aecEnabled by viewModel.aecEnabled.collectAsState()
|
||||
val debugReportAvailable by viewModel.debugReportAvailable.collectAsState()
|
||||
val debugReportStatus by viewModel.debugReportStatus.collectAsState()
|
||||
val seedHex by viewModel.seedHex.collectAsState()
|
||||
val alias by viewModel.alias.collectAsState()
|
||||
val recentRooms by viewModel.recentRooms.collectAsState()
|
||||
val pingResults by viewModel.pingResults.collectAsState()
|
||||
|
||||
var showManageRelays by remember { mutableStateOf(false) }
|
||||
val keyWarning by viewModel.keyWarning.collectAsState()
|
||||
|
||||
// Key-change warning dialog
|
||||
keyWarning?.let { info ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissKeyWarning() },
|
||||
title = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("\u26A0\uFE0F", fontSize = 40.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Server Key Changed", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"The relay's identity has changed since you last connected. " +
|
||||
"This usually happens when the server was restarted.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("Previously known", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(info.oldFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("New key", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(info.newFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { viewModel.acceptNewFingerprint() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFACC15))
|
||||
) {
|
||||
Text("Accept New Key", color = Color.Black, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.dismissKeyWarning() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Ping once on launch, then every 5 minutes
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSavedFingerprints()
|
||||
viewModel.pingAllServers()
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(300_000) // 5 minutes
|
||||
viewModel.pingAllServers()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = DarkBg
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (callState == 0) {
|
||||
// ── IDLE / CONNECT SCREEN ──
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "WarzonePhone",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "ENCRYPTED VOICE",
|
||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
|
||||
color = TextDim
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Relay selector button
|
||||
val selServer = servers.getOrNull(selectedServer)
|
||||
val selPing = selServer?.let { pingResults[it.address] }
|
||||
val selLock = selServer?.let { viewModel.lockStatus(it.address) } ?: LockStatus.UNKNOWN
|
||||
val lockEmoji = when (selLock) {
|
||||
LockStatus.VERIFIED -> "\uD83D\uDD12"
|
||||
LockStatus.NEW -> "\uD83D\uDD13"
|
||||
LockStatus.CHANGED -> "\u26A0\uFE0F"
|
||||
LockStatus.OFFLINE -> "\uD83D\uDD34"
|
||||
LockStatus.UNKNOWN -> "\u26AA"
|
||||
}
|
||||
|
||||
SectionLabel("RELAY")
|
||||
Surface(
|
||||
onClick = { showManageRelays = true },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = DarkSurface,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Text(text = lockEmoji, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = selServer?.let { "${it.label} (${it.address})" } ?: "No relay",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
selPing?.let {
|
||||
Text(
|
||||
text = "${it.rttMs}ms",
|
||||
color = if (it.rttMs > 200) Yellow else Green,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "\u2699", color = TextDim, fontSize = 16.sp) // ⚙
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Room
|
||||
SectionLabel("ROOM")
|
||||
OutlinedTextField(
|
||||
value = roomName,
|
||||
onValueChange = { viewModel.setRoomName(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Alias
|
||||
SectionLabel("ALIAS")
|
||||
OutlinedTextField(
|
||||
value = alias,
|
||||
onValueChange = { viewModel.setAlias(it) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// AEC + Settings
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = aecEnabled,
|
||||
onCheckedChange = { viewModel.setAecEnabled(it) }
|
||||
)
|
||||
Text("OS ECHO CANCEL", color = TextDim, style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Surface(
|
||||
onClick = onOpenSettings,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u2699", fontSize = 18.sp, color = TextDim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Connect button
|
||||
Button(
|
||||
onClick = { viewModel.startCall() },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Accent)
|
||||
) {
|
||||
Text(
|
||||
"Connect",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
errorMessage?.let { err ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = err, color = Red, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Identity
|
||||
val fp = if (seedHex.length >= 16) seedHex.take(16) else ""
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (fp.isNotEmpty()) {
|
||||
Identicon(fingerprint = seedHex, size = 28.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
CopyableFingerprint(
|
||||
fingerprint = fp.chunked(4).joinToString(":"),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = TextDim
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent rooms — grouped by server
|
||||
if (recentRooms.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val grouped = recentRooms.groupBy { it.relay }
|
||||
val serverColors = listOf(
|
||||
Color(0xFF0F3460), Color(0xFF3D0F60), Color(0xFF0F6034),
|
||||
Color(0xFF60300F), Color(0xFF0F4D60)
|
||||
)
|
||||
grouped.entries.forEachIndexed { sIdx, (relay, rooms) ->
|
||||
val serverLabel = servers.find { it.address == relay }?.label ?: relay
|
||||
val bgColor = serverColors[sIdx % serverColors.size]
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
rooms.forEach { recent ->
|
||||
Surface(
|
||||
onClick = {
|
||||
viewModel.setRoomName(recent.room)
|
||||
val idx = servers.indexOfFirst { it.address == recent.relay }
|
||||
if (idx >= 0) viewModel.selectServer(idx)
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = bgColor,
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = recent.room,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = serverLabel,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug report card
|
||||
if (debugReportAvailable || debugReportStatus != null) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
DebugReportCard(
|
||||
available = debugReportAvailable,
|
||||
status = debugReportStatus,
|
||||
onSend = { viewModel.sendDebugReport() },
|
||||
onDismiss = { viewModel.dismissDebugReport() }
|
||||
)
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── IN-CALL SCREEN ──
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Room name + settings gear
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = roomName,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Surface(
|
||||
onClick = onOpenSettings,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u2699", fontSize = 14.sp, color = TextDim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Green dot + timer
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Green)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
DurationDisplay(stats.durationSecs)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Audio level meter
|
||||
AudioLevelBar(stats.audioLevel)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Participants card
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = DarkSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.height(280.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (stats.roomParticipantCount > 0) {
|
||||
val unique = stats.roomParticipants
|
||||
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
|
||||
unique.forEach { member ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
Identicon(
|
||||
fingerprint = member.fingerprint.ifEmpty { member.displayName },
|
||||
size = 40.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = member.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
color = Color.White
|
||||
)
|
||||
if (member.fingerprint.isNotEmpty()) {
|
||||
CopyableFingerprint(
|
||||
fingerprint = member.fingerprint.take(16),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = TextDim,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Waiting for participants...",
|
||||
color = TextDim,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Controls: Mic / End / Spk
|
||||
ControlRow(
|
||||
isMuted = isMuted,
|
||||
isSpeaker = isSpeaker,
|
||||
onToggleMute = viewModel::toggleMute,
|
||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||
onHangUp = { viewModel.stopCall() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Stats
|
||||
Text(
|
||||
text = "TX: ${stats.framesEncoded} | RX: ${stats.framesDecoded}",
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = TextDim
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manage Relays Dialog ──
|
||||
if (showManageRelays) {
|
||||
ManageRelaysDialog(
|
||||
servers = servers,
|
||||
selectedServer = selectedServer,
|
||||
pingResults = pingResults,
|
||||
viewModel = viewModel,
|
||||
onSelect = { idx -> viewModel.selectServer(idx) },
|
||||
onDelete = { idx -> viewModel.removeServer(idx) },
|
||||
onAdd = { addr, label -> viewModel.addServer(addr, label) },
|
||||
onRefresh = { viewModel.pingAllServers() },
|
||||
onDismiss = { showManageRelays = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section label ──
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
|
||||
color = TextDim,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Manage Relays Dialog ──
|
||||
@Composable
|
||||
private fun ManageRelaysDialog(
|
||||
servers: List<ServerEntry>,
|
||||
selectedServer: Int,
|
||||
pingResults: Map<String, PingResult>,
|
||||
viewModel: CallViewModel,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
onAdd: (String, String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var addName by remember { mutableStateOf("") }
|
||||
var addAddr by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = DarkBg,
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Manage Relays", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Surface(
|
||||
onClick = onRefresh,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = DarkSurface2,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u21BB", color = TextDim, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
onClick = onDismiss,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = DarkSurface2,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u00D7", color = TextDim, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
servers.forEachIndexed { idx, entry ->
|
||||
val isSelected = idx == selectedServer
|
||||
val ping = pingResults[entry.address]
|
||||
val lock = viewModel.lockStatus(entry.address)
|
||||
val lockEmoji = when (lock) {
|
||||
LockStatus.VERIFIED -> "\uD83D\uDD12"
|
||||
LockStatus.NEW -> "\uD83D\uDD13"
|
||||
LockStatus.CHANGED -> "\u26A0\uFE0F"
|
||||
LockStatus.OFFLINE -> "\uD83D\uDD34"
|
||||
LockStatus.UNKNOWN -> ""
|
||||
}
|
||||
|
||||
Surface(
|
||||
onClick = { onSelect(idx) },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (isSelected) Color(0xFF0F3460) else DarkSurface,
|
||||
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Accent) else null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Identicon(
|
||||
fingerprint = ping?.serverFingerprint ?: entry.address,
|
||||
size = 36.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(entry.label, color = Color.White, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
entry.address,
|
||||
color = TextDim,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (lockEmoji.isNotEmpty()) Text(lockEmoji, fontSize = 14.sp)
|
||||
ping?.let {
|
||||
Text(
|
||||
"${it.rttMs}ms",
|
||||
color = if (it.rttMs > 200) Yellow else Green,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Surface(
|
||||
onClick = { onDelete(idx) },
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("\u00D7", color = TextDim, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Add relay inputs
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedTextField(
|
||||
value = addName,
|
||||
onValueChange = { addName = it },
|
||||
placeholder = { Text("Name", color = TextDim) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = addAddr,
|
||||
onValueChange = { addAddr = it },
|
||||
placeholder = { Text("host:port", color = TextDim) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (addAddr.isNotBlank()) {
|
||||
onAdd(addAddr.trim(), addName.ifBlank { addAddr }.trim())
|
||||
addName = ""; addAddr = ""
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Accent)
|
||||
) {
|
||||
Text("Add Relay", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duration display ──
|
||||
@Composable
|
||||
private fun DurationDisplay(durationSecs: Double) {
|
||||
val totalSeconds = durationSecs.roundToInt()
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
Text(
|
||||
text = "%d:%02d".format(minutes, seconds),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextDim
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audio level bar ──
|
||||
@Composable
|
||||
private fun AudioLevelBar(audioLevel: Int) {
|
||||
val level = if (audioLevel > 0) {
|
||||
(kotlin.math.ln(audioLevel.toFloat()) / kotlin.math.ln(32767f)).coerceIn(0f, 1f)
|
||||
} else 0f
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(DarkSurface)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(level)
|
||||
.height(4.dp)
|
||||
.background(
|
||||
brush = androidx.compose.ui.graphics.Brush.horizontalGradient(
|
||||
colors = listOf(Green, Yellow, Red)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Control row: Mic / End / Spk ──
|
||||
@Composable
|
||||
private fun ControlRow(
|
||||
isMuted: Boolean,
|
||||
isSpeaker: Boolean,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onHangUp: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Mic
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleMute,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = if (isMuted) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Red, contentColor = Color.White
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = DarkSurface2, contentColor = Color.White
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = if (isMuted) "Mic\nOff" else "Mic",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
// End
|
||||
FilledIconButton(
|
||||
onClick = onHangUp,
|
||||
modifier = Modifier.size(64.dp),
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = Accent, contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
|
||||
// Speaker
|
||||
FilledTonalIconButton(
|
||||
onClick = onToggleSpeaker,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = if (isSpeaker) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0xFF0F3460), contentColor = Color.White
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = DarkSurface2, contentColor = Color.White
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = if (isSpeaker) "Spk\nOn" else "Spk",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Debug report card ──
|
||||
@Composable
|
||||
private fun DebugReportCard(
|
||||
available: Boolean,
|
||||
status: String?,
|
||||
onSend: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = DarkSurface,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Debug Report",
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Email call recordings, logs & stats for analysis",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextDim,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when {
|
||||
status != null && status.startsWith("Error") -> {
|
||||
Text(text = status, style = MaterialTheme.typography.bodySmall, color = Red)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onSend) { Text("Retry") }
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
}
|
||||
}
|
||||
status != null && status != "ready" -> {
|
||||
Text(text = status, style = MaterialTheme.typography.bodySmall, color = TextDim)
|
||||
}
|
||||
available -> {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onSend,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Accent)
|
||||
) { Text("Email Report") }
|
||||
TextButton(onClick = onDismiss) { Text("Skip") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
141
android/app/src/main/java/com/wzp/ui/components/Identicon.kt
Normal file
@@ -0,0 +1,141 @@
|
||||
package com.wzp.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Deterministic identicon — generates a unique 5x5 symmetric pattern
|
||||
* from a hex fingerprint string. Identical algorithm to the desktop
|
||||
* TypeScript implementation in identicon.ts.
|
||||
*/
|
||||
@Composable
|
||||
fun Identicon(
|
||||
fingerprint: String,
|
||||
size: Dp = 36.dp,
|
||||
clickToCopy: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val bytes = hashBytes(fingerprint)
|
||||
val (bg, fg) = deriveColors(bytes)
|
||||
val grid = buildGrid(bytes)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(size * 0.12f))
|
||||
.then(
|
||||
if (clickToCopy && fingerprint.isNotEmpty()) {
|
||||
Modifier.clickable {
|
||||
clipboard.setText(AnnotatedString(fingerprint))
|
||||
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
val cellW = this.size.width / 5f
|
||||
val cellH = this.size.height / 5f
|
||||
|
||||
// Background
|
||||
drawRect(color = bg, size = this.size)
|
||||
|
||||
// Foreground cells
|
||||
for (y in 0 until 5) {
|
||||
for (x in 0 until 5) {
|
||||
if (grid[y][x]) {
|
||||
drawRect(
|
||||
color = fg,
|
||||
topLeft = Offset(x * cellW, y * cellH),
|
||||
size = Size(cellW, cellH),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint text that copies to clipboard on tap.
|
||||
*/
|
||||
@Composable
|
||||
fun CopyableFingerprint(
|
||||
fingerprint: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||
color: Color = Color.Unspecified,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
androidx.compose.material3.Text(
|
||||
text = fingerprint,
|
||||
style = style,
|
||||
color = color,
|
||||
modifier = modifier.clickable {
|
||||
if (fingerprint.isNotEmpty()) {
|
||||
clipboard.setText(AnnotatedString(fingerprint))
|
||||
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helpers (matching desktop identicon.ts) ---
|
||||
|
||||
private fun hashBytes(hex: String): List<Int> {
|
||||
val clean = hex.filter { it.isLetterOrDigit() }
|
||||
val bytes = mutableListOf<Int>()
|
||||
var i = 0
|
||||
while (i + 1 < clean.length) {
|
||||
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
|
||||
bytes.add(b)
|
||||
i += 2
|
||||
}
|
||||
// Pad to at least 16 bytes
|
||||
while (bytes.size < 16) bytes.add(0)
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
|
||||
val hue1 = bytes[0] * 360f / 256f
|
||||
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
|
||||
val bg = hslToColor(hue1, 0.65f, 0.35f)
|
||||
val fg = hslToColor(hue2, 0.70f, 0.55f)
|
||||
return bg to fg
|
||||
}
|
||||
|
||||
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
|
||||
return (0 until 5).map { y ->
|
||||
val left = (0 until 3).map { x ->
|
||||
val idx = 2 + y * 3 + x
|
||||
bytes[idx % bytes.size] > 128
|
||||
}
|
||||
// Mirror: col3 = col1, col4 = col0
|
||||
listOf(left[0], left[1], left[2], left[1], left[0])
|
||||
}
|
||||
}
|
||||
|
||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||
val k = { n: Float -> (n + h / 30f) % 12f }
|
||||
val a = s * min(l, 1f - l)
|
||||
val f = { n: Float ->
|
||||
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
|
||||
}
|
||||
return Color(f(0f), f(8f), f(4f))
|
||||
}
|
||||
567
android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
Normal file
567
android/app/src/main/java/com/wzp/ui/settings/SettingsScreen.kt
Normal file
@@ -0,0 +1,567 @@
|
||||
package com.wzp.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.wzp.ui.call.CallViewModel
|
||||
import com.wzp.ui.call.ServerEntry
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: CallViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Snapshot current values into local draft state
|
||||
val currentAlias by viewModel.alias.collectAsState()
|
||||
val currentSeedHex by viewModel.seedHex.collectAsState()
|
||||
val currentServers by viewModel.servers.collectAsState()
|
||||
val currentSelectedServer by viewModel.selectedServer.collectAsState()
|
||||
val currentRoomName by viewModel.roomName.collectAsState()
|
||||
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
|
||||
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
|
||||
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
|
||||
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
|
||||
|
||||
// Draft state — initialized from current values
|
||||
var draftAlias by remember { mutableStateOf(currentAlias) }
|
||||
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
|
||||
val draftServers = remember { currentServers.toMutableStateList() }
|
||||
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
|
||||
var draftRoomName by remember { mutableStateOf(currentRoomName) }
|
||||
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
|
||||
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
|
||||
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
|
||||
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
|
||||
|
||||
// Track if anything changed
|
||||
val hasChanges = draftAlias != currentAlias ||
|
||||
draftSeedHex != currentSeedHex ||
|
||||
draftServers.toList() != currentServers ||
|
||||
draftSelectedServer != currentSelectedServer ||
|
||||
draftRoomName != currentRoomName ||
|
||||
draftPreferIPv6 != currentPreferIPv6 ||
|
||||
draftPlayoutGain != currentPlayoutGain ||
|
||||
draftCaptureGain != currentCaptureGain ||
|
||||
draftAecEnabled != currentAecEnabled
|
||||
|
||||
var showAddServerDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreKeyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("< Back")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// Save button — only enabled when changes exist
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setAlias(draftAlias)
|
||||
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
|
||||
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
|
||||
viewModel.setRoomName(draftRoomName)
|
||||
viewModel.setPreferIPv6(draftPreferIPv6)
|
||||
viewModel.setPlayoutGainDb(draftPlayoutGain)
|
||||
viewModel.setCaptureGainDb(draftCaptureGain)
|
||||
viewModel.setAecEnabled(draftAecEnabled)
|
||||
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
},
|
||||
enabled = hasChanges
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// --- Identity ---
|
||||
SectionHeader("Identity")
|
||||
|
||||
OutlinedTextField(
|
||||
value = draftAlias,
|
||||
onValueChange = { draftAlias = it },
|
||||
label = { Text("Display Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Fingerprint display with identicon
|
||||
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
|
||||
Text(
|
||||
text = "Fingerprint",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
com.wzp.ui.components.Identicon(
|
||||
fingerprint = draftSeedHex,
|
||||
size = 40.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
com.wzp.ui.components.CopyableFingerprint(
|
||||
fingerprint = fingerprint.chunked(4).joinToString(" "),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Key backup/restore
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
|
||||
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Text("Copy Key")
|
||||
}
|
||||
OutlinedButton(onClick = { showRestoreKeyDialog = true }) {
|
||||
Text("Restore Key")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Audio ---
|
||||
SectionHeader("Audio Defaults")
|
||||
|
||||
GainSlider(
|
||||
label = "Voice Volume",
|
||||
gainDb = draftPlayoutGain,
|
||||
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
GainSlider(
|
||||
label = "Mic Gain",
|
||||
gainDb = draftCaptureGain,
|
||||
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Echo Cancellation (AEC)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Disable if audio sounds distorted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = draftAecEnabled,
|
||||
onCheckedChange = { draftAecEnabled = it }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
|
||||
val qualityLabels = listOf(
|
||||
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
|
||||
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
|
||||
)
|
||||
// Map slider position to JNI profile int:
|
||||
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
|
||||
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
|
||||
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
|
||||
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
|
||||
val qualityColors = listOf(
|
||||
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
|
||||
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
|
||||
)
|
||||
val currentCodec by viewModel.codecChoice.collectAsState()
|
||||
val sliderPos = profileToSlider[currentCodec] ?: 3
|
||||
Text("Quality", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = "Decode always accepts all codecs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = qualityLabels[sliderPos],
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = qualityColors[sliderPos]
|
||||
)
|
||||
Slider(
|
||||
value = sliderPos.toFloat(),
|
||||
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
|
||||
valueRange = 0f..7f,
|
||||
steps = 6,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
|
||||
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Servers ---
|
||||
SectionHeader("Servers")
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
draftServers.forEachIndexed { idx, entry ->
|
||||
val isSelected = draftSelectedServer == idx
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
FilledTonalIconButton(
|
||||
onClick = { draftSelectedServer = idx },
|
||||
modifier = Modifier
|
||||
.padding(end = 2.dp)
|
||||
.height(36.dp)
|
||||
.width(140.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = if (isSelected) {
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
} else {
|
||||
IconButtonDefaults.filledTonalIconButtonColors()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = entry.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
// Show remove button for non-default servers
|
||||
if (idx >= 2) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
draftServers.removeAt(idx)
|
||||
if (draftSelectedServer >= draftServers.size) {
|
||||
draftSelectedServer = 0
|
||||
}
|
||||
},
|
||||
modifier = Modifier.height(36.dp)
|
||||
) {
|
||||
Text("X", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showAddServerDialog = true },
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("+ Add Server")
|
||||
}
|
||||
|
||||
// Show selected server address
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Network ---
|
||||
SectionHeader("Network")
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Prefer IPv6",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = draftPreferIPv6,
|
||||
onCheckedChange = { draftPreferIPv6 = it }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// --- Room ---
|
||||
SectionHeader("Room")
|
||||
|
||||
OutlinedTextField(
|
||||
value = draftRoomName,
|
||||
onValueChange = { draftRoomName = it },
|
||||
label = { Text("Default Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddServerDialog) {
|
||||
AddServerDialog(
|
||||
onDismiss = { showAddServerDialog = false },
|
||||
onAdd = { host, port, label ->
|
||||
draftServers.add(ServerEntry("$host:$port", label))
|
||||
showAddServerDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showRestoreKeyDialog) {
|
||||
RestoreKeyDialog(
|
||||
onDismiss = { showRestoreKeyDialog = false },
|
||||
onRestore = { hex ->
|
||||
draftSeedHex = hex
|
||||
showRestoreKeyDialog = false
|
||||
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val sign = if (gainDb >= 0) "+" else ""
|
||||
Text(
|
||||
text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Slider(
|
||||
value = gainDb,
|
||||
onValueChange = onGainChange,
|
||||
valueRange = -20f..20f,
|
||||
steps = 0,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddServerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (host: String, port: String, label: String) -> Unit
|
||||
) {
|
||||
var host by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("4433") }
|
||||
var label by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add Server") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host (IP or domain)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
label = { Text("Label (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (host.isNotBlank()) {
|
||||
val displayLabel = label.ifBlank { host }
|
||||
onAdd(host.trim(), port.trim(), displayLabel)
|
||||
}
|
||||
}
|
||||
) { Text("Add") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreKeyDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onRestore: (hex: String) -> Unit
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Restore Identity Key") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Paste your 64-character hex key below. This will replace your current identity.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it.trim().lowercase()
|
||||
error = null
|
||||
},
|
||||
label = { Text("Identity Key (hex)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = error != null
|
||||
)
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val cleaned = keyInput.replace("\\s".toRegex(), "")
|
||||
if (cleaned.length != 64 || !cleaned.all { it in '0'..'9' || it in 'a'..'f' }) {
|
||||
error = "Key must be exactly 64 hex characters"
|
||||
} else {
|
||||
onRestore(cleaned)
|
||||
}
|
||||
}
|
||||
) { Text("Restore") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
4
android/app/src/main/res/xml/file_paths.xml
Normal file
4
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="debug" path="." />
|
||||
</paths>
|
||||
4
android/build.gradle.kts
Normal file
4
android/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
5
android/gradlew
vendored
Executable file
5
android/gradlew
vendored
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Gradle wrapper script
|
||||
APP_HOME=$(cd "$(dirname "$0")" && pwd)
|
||||
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
||||
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "WZPhone"
|
||||
include(":app")
|
||||
34
crates/wzp-android/Cargo.toml
Normal file
34
crates/wzp-android/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "wzp-android"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "WarzonePhone Android native VoIP engine — Oboe audio, JNI bridge, call pipeline"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wzp-proto = { workspace = true }
|
||||
wzp-codec = { workspace = true }
|
||||
wzp-fec = { workspace = true }
|
||||
wzp-crypto = { workspace = true }
|
||||
wzp-transport = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = "1"
|
||||
libc = "0.2"
|
||||
jni = { version = "0.21", default-features = false }
|
||||
rand = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
tracing-android = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
154
crates/wzp-android/build.rs
Normal file
154
crates/wzp-android/build.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
|
||||
if target.contains("android") {
|
||||
// Override broken static getauxval from compiler-rt that crashes
|
||||
// in shared libraries. Must be compiled first to take link priority.
|
||||
cc::Build::new()
|
||||
.file("cpp/getauxval_fix.c")
|
||||
.compile("getauxval_fix");
|
||||
|
||||
let oboe_dir = fetch_oboe();
|
||||
match oboe_dir {
|
||||
Some(oboe_path) => {
|
||||
println!("cargo:warning=Building with Oboe from {:?}", oboe_path);
|
||||
|
||||
let mut build = cc::Build::new();
|
||||
build
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
// Use shared libc++ — avoids pulling in static libc stubs
|
||||
// that crash in shared libraries (getauxval, pthread_create, etc.)
|
||||
.cpp_link_stdlib(Some("c++_shared"))
|
||||
.include("cpp")
|
||||
.include(oboe_path.join("include"))
|
||||
.include(oboe_path.join("src"))
|
||||
.define("WZP_HAS_OBOE", None)
|
||||
.file("cpp/oboe_bridge.cpp");
|
||||
|
||||
// Compile all Oboe source files
|
||||
let src_dir = oboe_path.join("src");
|
||||
add_cpp_files_recursive(&mut build, &src_dir);
|
||||
|
||||
build.compile("oboe_bridge");
|
||||
}
|
||||
None => {
|
||||
println!("cargo:warning=Oboe not found, building with stub");
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.cpp_link_stdlib(Some("c++_shared"))
|
||||
.file("cpp/oboe_stub.cpp")
|
||||
.include("cpp")
|
||||
.compile("oboe_bridge");
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic C++ runtime — libc++_shared.so must be in jniLibs alongside
|
||||
// libwzp_android.so. We copy it there from the NDK sysroot.
|
||||
//
|
||||
// WHY NOT STATIC: libc++_static.a + libc++abi.a transitively pull in
|
||||
// object files from libc.a (static libc) which contain broken stubs for
|
||||
// getauxval, __init_tcb, pthread_create, etc. These stubs only work in
|
||||
// statically-linked executables. In shared libraries loaded by dlopen(),
|
||||
// they SIGSEGV because the static libc init hasn't run.
|
||||
// Google's official recommendation: use libc++_shared.so for native libs.
|
||||
if let Ok(ndk) = std::env::var("ANDROID_NDK_HOME") {
|
||||
let arch = if target.contains("aarch64") {
|
||||
"aarch64-linux-android"
|
||||
} else if target.contains("armv7") {
|
||||
"arm-linux-androideabi"
|
||||
} else if target.contains("x86_64") {
|
||||
"x86_64-linux-android"
|
||||
} else {
|
||||
"aarch64-linux-android"
|
||||
};
|
||||
let lib_dir = format!(
|
||||
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}"
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={lib_dir}");
|
||||
|
||||
// Copy libc++_shared.so to the jniLibs directory
|
||||
let shared_so = format!("{lib_dir}/libc++_shared.so");
|
||||
if std::path::Path::new(&shared_so).exists() {
|
||||
let jni_abi = if target.contains("aarch64") {
|
||||
"arm64-v8a"
|
||||
} else if target.contains("armv7") {
|
||||
"armeabi-v7a"
|
||||
} else {
|
||||
"arm64-v8a"
|
||||
};
|
||||
// Try to copy to the Gradle jniLibs directory
|
||||
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let jni_dir = format!(
|
||||
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
|
||||
);
|
||||
if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
|
||||
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
||||
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Oboe needs liblog and libOpenSLES from Android
|
||||
println!("cargo:rustc-link-lib=log");
|
||||
println!("cargo:rustc-link-lib=OpenSLES");
|
||||
} else {
|
||||
// Non-Android: always use stub
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.file("cpp/oboe_stub.cpp")
|
||||
.include("cpp")
|
||||
.compile("oboe_bridge");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively add all .cpp files from a directory to a cc::Build.
|
||||
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
||||
if !dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
add_cpp_files_recursive(build, &path);
|
||||
} else if path.extension().map_or(false, |e| e == "cpp") {
|
||||
build.file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to find or fetch Oboe headers + source.
|
||||
fn fetch_oboe() -> Option<PathBuf> {
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let oboe_dir = out_dir.join("oboe");
|
||||
|
||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||
return Some(oboe_dir);
|
||||
}
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth=1",
|
||||
"--branch=1.8.1",
|
||||
"https://github.com/google/oboe.git",
|
||||
oboe_dir.to_str().unwrap(),
|
||||
])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
||||
Some(oboe_dir)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
21
crates/wzp-android/cpp/getauxval_fix.c
Normal file
21
crates/wzp-android/cpp/getauxval_fix.c
Normal file
@@ -0,0 +1,21 @@
|
||||
// Override the broken static getauxval from compiler-rt/CRT.
|
||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
||||
// This version calls the real bionic getauxval via dlsym.
|
||||
#ifdef __ANDROID__
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
||||
|
||||
unsigned long getauxval(unsigned long type) {
|
||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
||||
if (!real_getauxval) {
|
||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
||||
if (!real_getauxval) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return real_getauxval(type);
|
||||
}
|
||||
#endif
|
||||
278
crates/wzp-android/cpp/oboe_bridge.cpp
Normal file
278
crates/wzp-android/cpp/oboe_bridge.cpp
Normal file
@@ -0,0 +1,278 @@
|
||||
// Full Oboe implementation for Android
|
||||
// This file is compiled only when targeting Android
|
||||
|
||||
#include "oboe_bridge.h"
|
||||
|
||||
#ifdef __ANDROID__
|
||||
#include <oboe/Oboe.h>
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <atomic>
|
||||
|
||||
#define LOG_TAG "wzp-oboe"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ring buffer helpers (SPSC, lock-free)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
||||
const wzp_atomic_int* read_idx,
|
||||
int32_t capacity) {
|
||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
||||
int32_t avail = w - r;
|
||||
if (avail < 0) avail += capacity;
|
||||
return avail;
|
||||
}
|
||||
|
||||
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
||||
const wzp_atomic_int* read_idx,
|
||||
int32_t capacity) {
|
||||
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
||||
}
|
||||
|
||||
static inline void ring_write(int16_t* buf, int32_t capacity,
|
||||
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
||||
const int16_t* src, int32_t count) {
|
||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
||||
for (int32_t i = 0; i < count; i++) {
|
||||
buf[w] = src[i];
|
||||
w++;
|
||||
if (w >= capacity) w = 0;
|
||||
}
|
||||
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
||||
}
|
||||
|
||||
static inline void ring_read(int16_t* buf, int32_t capacity,
|
||||
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
||||
int16_t* dst, int32_t count) {
|
||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
||||
for (int32_t i = 0; i < count; i++) {
|
||||
dst[i] = buf[r];
|
||||
r++;
|
||||
if (r >= capacity) r = 0;
|
||||
}
|
||||
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
||||
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
||||
static const WzpOboeRings* g_rings = nullptr;
|
||||
static std::atomic<bool> g_running{false};
|
||||
static std::atomic<float> g_capture_latency_ms{0.0f};
|
||||
static std::atomic<float> g_playout_latency_ms{0.0f};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
||||
public:
|
||||
oboe::DataCallbackResult onAudioReady(
|
||||
oboe::AudioStream* stream,
|
||||
void* audioData,
|
||||
int32_t numFrames) override {
|
||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
||||
return oboe::DataCallbackResult::Stop;
|
||||
}
|
||||
|
||||
const int16_t* src = static_cast<const int16_t*>(audioData);
|
||||
int32_t avail = ring_available_write(g_rings->capture_write_idx,
|
||||
g_rings->capture_read_idx,
|
||||
g_rings->capture_capacity);
|
||||
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
||||
if (to_write > 0) {
|
||||
ring_write(g_rings->capture_buf, g_rings->capture_capacity,
|
||||
g_rings->capture_write_idx, g_rings->capture_read_idx,
|
||||
src, to_write);
|
||||
}
|
||||
|
||||
// Update latency estimate
|
||||
auto result = stream->calculateLatencyMillis();
|
||||
if (result) {
|
||||
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playout callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
||||
public:
|
||||
oboe::DataCallbackResult onAudioReady(
|
||||
oboe::AudioStream* stream,
|
||||
void* audioData,
|
||||
int32_t numFrames) override {
|
||||
if (!g_running.load(std::memory_order_relaxed) || !g_rings) {
|
||||
memset(audioData, 0, numFrames * sizeof(int16_t));
|
||||
return oboe::DataCallbackResult::Stop;
|
||||
}
|
||||
|
||||
int16_t* dst = static_cast<int16_t*>(audioData);
|
||||
int32_t avail = ring_available_read(g_rings->playout_write_idx,
|
||||
g_rings->playout_read_idx,
|
||||
g_rings->playout_capacity);
|
||||
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
||||
|
||||
if (to_read > 0) {
|
||||
ring_read(g_rings->playout_buf, g_rings->playout_capacity,
|
||||
g_rings->playout_write_idx, g_rings->playout_read_idx,
|
||||
dst, to_read);
|
||||
}
|
||||
// Fill remainder with silence on underrun
|
||||
if (to_read < numFrames) {
|
||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
||||
}
|
||||
|
||||
// Update latency estimate
|
||||
auto result = stream->calculateLatencyMillis();
|
||||
if (result) {
|
||||
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
};
|
||||
|
||||
static CaptureCallback g_capture_cb;
|
||||
static PlayoutCallback g_playout_cb;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public C API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
if (g_running.load(std::memory_order_relaxed)) {
|
||||
LOGW("wzp_oboe_start: already running");
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_rings = rings;
|
||||
|
||||
// Build capture stream
|
||||
oboe::AudioStreamBuilder captureBuilder;
|
||||
captureBuilder.setDirection(oboe::Direction::Input)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
||||
->setDataCallback(&g_capture_cb);
|
||||
|
||||
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
||||
return -2;
|
||||
}
|
||||
|
||||
// Build playout stream
|
||||
oboe::AudioStreamBuilder playoutBuilder;
|
||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
||||
->setFormat(oboe::AudioFormat::I16)
|
||||
->setChannelCount(config->channel_count)
|
||||
->setSampleRate(config->sample_rate)
|
||||
->setFramesPerDataCallback(config->frames_per_burst)
|
||||
->setUsage(oboe::Usage::VoiceCommunication)
|
||||
->setDataCallback(&g_playout_cb);
|
||||
|
||||
result = playoutBuilder.openStream(g_playout_stream);
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
||||
g_capture_stream->close();
|
||||
g_capture_stream.reset();
|
||||
return -3;
|
||||
}
|
||||
|
||||
g_running.store(true, std::memory_order_release);
|
||||
|
||||
// Start both streams
|
||||
result = g_capture_stream->requestStart();
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
||||
g_running.store(false, std::memory_order_release);
|
||||
g_capture_stream->close();
|
||||
g_playout_stream->close();
|
||||
g_capture_stream.reset();
|
||||
g_playout_stream.reset();
|
||||
return -4;
|
||||
}
|
||||
|
||||
result = g_playout_stream->requestStart();
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
||||
g_running.store(false, std::memory_order_release);
|
||||
g_capture_stream->requestStop();
|
||||
g_capture_stream->close();
|
||||
g_playout_stream->close();
|
||||
g_capture_stream.reset();
|
||||
g_playout_stream.reset();
|
||||
return -5;
|
||||
}
|
||||
|
||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {
|
||||
g_running.store(false, std::memory_order_release);
|
||||
|
||||
if (g_capture_stream) {
|
||||
g_capture_stream->requestStop();
|
||||
g_capture_stream->close();
|
||||
g_capture_stream.reset();
|
||||
}
|
||||
if (g_playout_stream) {
|
||||
g_playout_stream->requestStop();
|
||||
g_playout_stream->close();
|
||||
g_playout_stream.reset();
|
||||
}
|
||||
|
||||
g_rings = nullptr;
|
||||
LOGI("Oboe stopped");
|
||||
}
|
||||
|
||||
float wzp_oboe_capture_latency_ms(void) {
|
||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
float wzp_oboe_playout_latency_ms(void) {
|
||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int wzp_oboe_is_running(void) {
|
||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
||||
}
|
||||
|
||||
#else
|
||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
||||
// Provide empty implementations just in case.
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(void)config; (void)rings;
|
||||
return -99;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {}
|
||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
||||
int wzp_oboe_is_running(void) { return 0; }
|
||||
|
||||
#endif // __ANDROID__
|
||||
43
crates/wzp-android/cpp/oboe_bridge.h
Normal file
43
crates/wzp-android/cpp/oboe_bridge.h
Normal file
@@ -0,0 +1,43 @@
|
||||
#ifndef WZP_OBOE_BRIDGE_H
|
||||
#define WZP_OBOE_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
||||
extern "C" {
|
||||
#else
|
||||
#include <stdatomic.h>
|
||||
typedef atomic_int wzp_atomic_int;
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
int32_t sample_rate;
|
||||
int32_t frames_per_burst;
|
||||
int32_t channel_count;
|
||||
} WzpOboeConfig;
|
||||
|
||||
typedef struct {
|
||||
int16_t* capture_buf;
|
||||
int32_t capture_capacity;
|
||||
wzp_atomic_int* capture_write_idx;
|
||||
wzp_atomic_int* capture_read_idx;
|
||||
|
||||
int16_t* playout_buf;
|
||||
int32_t playout_capacity;
|
||||
wzp_atomic_int* playout_write_idx;
|
||||
wzp_atomic_int* playout_read_idx;
|
||||
} WzpOboeRings;
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
||||
void wzp_oboe_stop(void);
|
||||
float wzp_oboe_capture_latency_ms(void);
|
||||
float wzp_oboe_playout_latency_ms(void);
|
||||
int wzp_oboe_is_running(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // WZP_OBOE_BRIDGE_H
|
||||
27
crates/wzp-android/cpp/oboe_stub.cpp
Normal file
27
crates/wzp-android/cpp/oboe_stub.cpp
Normal file
@@ -0,0 +1,27 @@
|
||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
||||
|
||||
#include "oboe_bridge.h"
|
||||
#include <stdio.h>
|
||||
|
||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
||||
(void)config;
|
||||
(void)rings;
|
||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void wzp_oboe_stop(void) {
|
||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
||||
}
|
||||
|
||||
float wzp_oboe_capture_latency_ms(void) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
float wzp_oboe_playout_latency_ms(void) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
int wzp_oboe_is_running(void) {
|
||||
return 0;
|
||||
}
|
||||
424
crates/wzp-android/src/audio_android.rs
Normal file
424
crates/wzp-android/src/audio_android.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
//! Lock-free SPSC ring buffer audio backend for Android (Oboe).
|
||||
//!
|
||||
//! The ring buffers are shared between Rust and C++: the Oboe callbacks
|
||||
//! (running on a high-priority audio thread) read/write directly into
|
||||
//! the buffers via atomic indices, while the Rust codec thread on the
|
||||
//! other side does the same.
|
||||
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
use tracing::info;
|
||||
#[allow(unused_imports)]
|
||||
use tracing::warn;
|
||||
|
||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||
pub const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
/// Default ring buffer capacity: 8 frames = 160 ms at 48 kHz.
|
||||
const RING_CAPACITY: usize = 7680;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations matching oboe_bridge.h
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct WzpOboeConfig {
|
||||
sample_rate: i32,
|
||||
frames_per_burst: i32,
|
||||
channel_count: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_snake_case)]
|
||||
struct WzpOboeRings {
|
||||
capture_buf: *mut i16,
|
||||
capture_capacity: i32,
|
||||
capture_write_idx: *mut AtomicI32,
|
||||
capture_read_idx: *mut AtomicI32,
|
||||
|
||||
playout_buf: *mut i16,
|
||||
playout_capacity: i32,
|
||||
playout_write_idx: *mut AtomicI32,
|
||||
playout_read_idx: *mut AtomicI32,
|
||||
}
|
||||
|
||||
unsafe impl Send for WzpOboeRings {}
|
||||
unsafe impl Sync for WzpOboeRings {}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
||||
fn wzp_oboe_stop();
|
||||
fn wzp_oboe_capture_latency_ms() -> f32;
|
||||
fn wzp_oboe_playout_latency_ms() -> f32;
|
||||
fn wzp_oboe_is_running() -> i32;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SPSC Ring Buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Single-producer single-consumer lock-free ring buffer.
|
||||
///
|
||||
/// The producer calls `write()` and the consumer calls `read()`.
|
||||
/// Atomics use acquire/release ordering to ensure correct visibility
|
||||
/// across the Oboe audio thread and the Rust codec thread.
|
||||
pub struct RingBuffer {
|
||||
buf: Vec<i16>,
|
||||
capacity: usize,
|
||||
write_idx: AtomicI32,
|
||||
read_idx: AtomicI32,
|
||||
}
|
||||
|
||||
impl RingBuffer {
|
||||
/// Create a new ring buffer with the given capacity (in samples).
|
||||
///
|
||||
/// The actual usable capacity is `capacity - 1` to distinguish
|
||||
/// full from empty.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buf: vec![0i16; capacity],
|
||||
capacity,
|
||||
write_idx: AtomicI32::new(0),
|
||||
read_idx: AtomicI32::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read.
|
||||
pub fn available_read(&self) -> usize {
|
||||
let w = self.write_idx.load(Ordering::Acquire);
|
||||
let r = self.read_idx.load(Ordering::Relaxed);
|
||||
let avail = w - r;
|
||||
if avail < 0 {
|
||||
(avail + self.capacity as i32) as usize
|
||||
} else {
|
||||
avail as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples that can be written before the buffer is full.
|
||||
pub fn available_write(&self) -> usize {
|
||||
self.capacity - 1 - self.available_read()
|
||||
}
|
||||
|
||||
/// Write samples into the ring buffer (producer side).
|
||||
///
|
||||
/// Returns the number of samples actually written (may be less than
|
||||
/// `data.len()` if the buffer is nearly full).
|
||||
pub fn write(&self, data: &[i16]) -> usize {
|
||||
let avail = self.available_write();
|
||||
let count = data.len().min(avail);
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
||||
let cap = self.capacity;
|
||||
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
||||
|
||||
for i in 0..count {
|
||||
// SAFETY: w is always in [0, capacity) and we are the sole producer.
|
||||
unsafe {
|
||||
*buf_ptr.add(w) = data[i];
|
||||
}
|
||||
w += 1;
|
||||
if w >= cap {
|
||||
w = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.write_idx.store(w as i32, Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring buffer (consumer side).
|
||||
///
|
||||
/// Returns the number of samples actually read (may be less than
|
||||
/// `out.len()` if the buffer doesn't have enough data).
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let avail = self.available_read();
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
||||
let cap = self.capacity;
|
||||
let buf_ptr = self.buf.as_ptr();
|
||||
|
||||
for i in 0..count {
|
||||
// SAFETY: r is always in [0, capacity) and we are the sole consumer.
|
||||
unsafe {
|
||||
out[i] = *buf_ptr.add(r);
|
||||
}
|
||||
r += 1;
|
||||
if r >= cap {
|
||||
r = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.read_idx.store(r as i32, Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Get a raw pointer to the buffer data (for FFI).
|
||||
fn buf_ptr(&self) -> *mut i16 {
|
||||
self.buf.as_ptr() as *mut i16
|
||||
}
|
||||
|
||||
/// Get a raw pointer to the write index atomic (for FFI).
|
||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
||||
}
|
||||
|
||||
/// Get a raw pointer to the read index atomic (for FFI).
|
||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: The ring buffer is designed for SPSC use where producer and consumer
|
||||
// are on different threads. The atomic indices provide the synchronization.
|
||||
unsafe impl Send for RingBuffer {}
|
||||
unsafe impl Sync for RingBuffer {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oboe Backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Oboe-based audio backend for Android.
|
||||
///
|
||||
/// Owns two SPSC ring buffers (capture and playout) that are shared with
|
||||
/// the C++ Oboe callbacks via raw pointers. The Oboe callbacks run on
|
||||
/// high-priority audio threads managed by the Android audio system.
|
||||
pub struct OboeBackend {
|
||||
capture_ring: RingBuffer,
|
||||
playout_ring: RingBuffer,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl OboeBackend {
|
||||
/// Create a new backend with default ring buffer sizes (160 ms each).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
capture_ring: RingBuffer::new(RING_CAPACITY),
|
||||
playout_ring: RingBuffer::new(RING_CAPACITY),
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start Oboe audio streams.
|
||||
///
|
||||
/// This sets up the ring buffer pointers and calls into the C++ layer
|
||||
/// to open and start the capture and playout Oboe streams.
|
||||
pub fn start(&mut self) -> Result<(), anyhow::Error> {
|
||||
if self.started {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = WzpOboeConfig {
|
||||
sample_rate: 48_000,
|
||||
frames_per_burst: FRAME_SAMPLES as i32,
|
||||
channel_count: 1,
|
||||
};
|
||||
|
||||
let rings = WzpOboeRings {
|
||||
capture_buf: self.capture_ring.buf_ptr(),
|
||||
capture_capacity: self.capture_ring.capacity as i32,
|
||||
capture_write_idx: self.capture_ring.write_idx_ptr(),
|
||||
capture_read_idx: self.capture_ring.read_idx_ptr(),
|
||||
|
||||
playout_buf: self.playout_ring.buf_ptr(),
|
||||
playout_capacity: self.playout_ring.capacity as i32,
|
||||
playout_write_idx: self.playout_ring.write_idx_ptr(),
|
||||
playout_read_idx: self.playout_ring.read_idx_ptr(),
|
||||
};
|
||||
|
||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
||||
if ret != 0 {
|
||||
return Err(anyhow::anyhow!("wzp_oboe_start failed with code {}", ret));
|
||||
}
|
||||
|
||||
self.started = true;
|
||||
info!("Oboe backend started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop Oboe audio streams.
|
||||
pub fn stop(&mut self) {
|
||||
if !self.started {
|
||||
return;
|
||||
}
|
||||
unsafe { wzp_oboe_stop() };
|
||||
self.started = false;
|
||||
info!("Oboe backend stopped");
|
||||
}
|
||||
|
||||
/// Read captured audio samples from the capture ring buffer.
|
||||
///
|
||||
/// Returns the number of samples actually read. The caller should
|
||||
/// provide a buffer of at least `FRAME_SAMPLES` (960) samples.
|
||||
pub fn read_capture(&self, out: &mut [i16]) -> usize {
|
||||
self.capture_ring.read(out)
|
||||
}
|
||||
|
||||
/// Write audio samples to the playout ring buffer.
|
||||
///
|
||||
/// Returns the number of samples actually written.
|
||||
pub fn write_playout(&self, samples: &[i16]) -> usize {
|
||||
self.playout_ring.write(samples)
|
||||
}
|
||||
|
||||
/// Get the current capture latency in milliseconds (from Oboe).
|
||||
#[allow(unused)]
|
||||
pub fn capture_latency_ms(&self) -> f32 {
|
||||
unsafe { wzp_oboe_capture_latency_ms() }
|
||||
}
|
||||
|
||||
/// Get the current playout latency in milliseconds (from Oboe).
|
||||
#[allow(unused)]
|
||||
pub fn playout_latency_ms(&self) -> f32 {
|
||||
unsafe { wzp_oboe_playout_latency_ms() }
|
||||
}
|
||||
|
||||
/// Check if the Oboe streams are currently running.
|
||||
#[allow(unused)]
|
||||
pub fn is_running(&self) -> bool {
|
||||
unsafe { wzp_oboe_is_running() != 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OboeBackend {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread affinity / priority helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pin the current thread to the highest-numbered CPU cores (big cores on
|
||||
/// ARM big.LITTLE architectures). Falls back silently on failure.
|
||||
#[allow(unused)]
|
||||
pub fn pin_to_big_core() {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
unsafe {
|
||||
let num_cpus = libc::sysconf(libc::_SC_NPROCESSORS_ONLN);
|
||||
if num_cpus <= 0 {
|
||||
warn!("pin_to_big_core: could not determine CPU count");
|
||||
return;
|
||||
}
|
||||
let num_cpus = num_cpus as usize;
|
||||
|
||||
// Target the upper half of CPUs (big cores on most big.LITTLE SoCs)
|
||||
let start = num_cpus / 2;
|
||||
let mut set: libc::cpu_set_t = std::mem::zeroed();
|
||||
libc::CPU_ZERO(&mut set);
|
||||
for cpu in start..num_cpus {
|
||||
libc::CPU_SET(cpu, &mut set);
|
||||
}
|
||||
|
||||
let ret = libc::sched_setaffinity(
|
||||
0, // current thread
|
||||
std::mem::size_of::<libc::cpu_set_t>(),
|
||||
&set,
|
||||
);
|
||||
if ret != 0 {
|
||||
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error());
|
||||
} else {
|
||||
info!(start, num_cpus, "pinned to big cores");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// No-op on non-Android
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to set SCHED_FIFO real-time priority for the current thread.
|
||||
/// Falls back silently on failure (requires appropriate permissions on Android).
|
||||
#[allow(unused)]
|
||||
pub fn set_realtime_priority() {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
unsafe {
|
||||
let param = libc::sched_param {
|
||||
sched_priority: 2, // Low RT priority — enough for audio, safe
|
||||
};
|
||||
let ret = libc::sched_setscheduler(0, libc::SCHED_FIFO, ¶m);
|
||||
if ret != 0 {
|
||||
warn!(
|
||||
"sched_setscheduler(SCHED_FIFO) failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
} else {
|
||||
info!("set SCHED_FIFO priority 2");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// No-op on non-Android
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_write_read() {
|
||||
let ring = RingBuffer::new(16);
|
||||
let data = [1i16, 2, 3, 4, 5];
|
||||
assert_eq!(ring.write(&data), 5);
|
||||
assert_eq!(ring.available_read(), 5);
|
||||
|
||||
let mut out = [0i16; 5];
|
||||
assert_eq!(ring.read(&mut out), 5);
|
||||
assert_eq!(out, [1, 2, 3, 4, 5]);
|
||||
assert_eq!(ring.available_read(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_wraparound() {
|
||||
let ring = RingBuffer::new(8);
|
||||
let data = [10i16, 20, 30, 40, 50, 60]; // 6 samples, capacity 8 (usable 7)
|
||||
assert_eq!(ring.write(&data), 6);
|
||||
|
||||
let mut out = [0i16; 4];
|
||||
assert_eq!(ring.read(&mut out), 4);
|
||||
assert_eq!(out, [10, 20, 30, 40]);
|
||||
|
||||
// Now write more, which should wrap around
|
||||
let data2 = [70i16, 80, 90, 100];
|
||||
assert_eq!(ring.write(&data2), 4);
|
||||
|
||||
let mut out2 = [0i16; 6];
|
||||
assert_eq!(ring.read(&mut out2), 6);
|
||||
assert_eq!(out2, [50, 60, 70, 80, 90, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_full() {
|
||||
let ring = RingBuffer::new(4); // usable capacity = 3
|
||||
let data = [1i16, 2, 3, 4, 5];
|
||||
assert_eq!(ring.write(&data), 3); // Only 3 fit
|
||||
assert_eq!(ring.available_write(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oboe_backend_stub_start_stop() {
|
||||
let mut backend = OboeBackend::new();
|
||||
backend.start().expect("stub start should succeed");
|
||||
assert!(backend.started);
|
||||
backend.stop();
|
||||
assert!(!backend.started);
|
||||
}
|
||||
}
|
||||
128
crates/wzp-android/src/audio_ring.rs
Normal file
128
crates/wzp-android/src/audio_ring.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||
//!
|
||||
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||
//!
|
||||
//! On overflow (writer laps the reader), the writer simply overwrites
|
||||
//! old buffer data. The reader detects the lap via `available() >
|
||||
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
||||
//!
|
||||
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
|
||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
||||
const RING_CAPACITY: usize = 16384; // 2^14
|
||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
|
||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16]>,
|
||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||
write_pos: AtomicUsize,
|
||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||
read_pos: AtomicUsize,
|
||||
/// Incremented by reader when it detects it was lapped (overflow).
|
||||
overflow_count: AtomicU64,
|
||||
/// Incremented by reader when ring is empty (underrun).
|
||||
underrun_count: AtomicU64,
|
||||
}
|
||||
|
||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||
unsafe impl Send for AudioRing {}
|
||||
unsafe impl Sync for AudioRing {}
|
||||
|
||||
impl AudioRing {
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
underrun_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples available to read (clamped to capacity).
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||
}
|
||||
|
||||
/// Number of samples that can be written without overwriting unread data.
|
||||
pub fn free_space(&self) -> usize {
|
||||
RING_CAPACITY.saturating_sub(self.available())
|
||||
}
|
||||
|
||||
/// Write samples into the ring. Returns number of samples written.
|
||||
///
|
||||
/// If the ring is full, old data is silently overwritten. The reader
|
||||
/// will detect the lap and self-correct. The writer NEVER touches
|
||||
/// `read_pos` — this is the key invariant that prevents cursor desync.
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
|
||||
for i in 0..count {
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||
///
|
||||
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
||||
/// forward to the oldest valid data. This is safe because only the
|
||||
/// reader thread writes `read_pos`.
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||
|
||||
let mut avail = w.wrapping_sub(r);
|
||||
|
||||
// Lap detection: writer has overwritten our unread data.
|
||||
// Snap read_pos forward to oldest valid data in the buffer.
|
||||
if avail > RING_CAPACITY {
|
||||
r = w.wrapping_sub(RING_CAPACITY);
|
||||
avail = RING_CAPACITY;
|
||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
if w == r {
|
||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
for i in 0..count {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
}
|
||||
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of overflow events (reader was lapped by writer).
|
||||
pub fn overflow_count(&self) -> u64 {
|
||||
self.overflow_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Number of underrun events (reader found empty buffer).
|
||||
pub fn underrun_count(&self) -> u64 {
|
||||
self.underrun_count.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
15
crates/wzp-android/src/commands.rs
Normal file
15
crates/wzp-android/src/commands.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Engine commands sent from the JNI/UI thread to the engine.
|
||||
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
/// Commands that can be sent to the running engine.
|
||||
pub enum EngineCommand {
|
||||
/// Mute or unmute the microphone.
|
||||
SetMute(bool),
|
||||
/// Enable or disable speaker (loudspeaker) mode.
|
||||
SetSpeaker(bool),
|
||||
/// Force a specific quality profile (overrides adaptive logic).
|
||||
ForceProfile(QualityProfile),
|
||||
/// Stop the call and shut down the engine.
|
||||
Stop,
|
||||
}
|
||||
803
crates/wzp-android/src/engine.rs
Normal file
803
crates/wzp-android/src/engine.rs
Normal file
@@ -0,0 +1,803 @@
|
||||
//! Engine orchestrator — manages the call lifecycle.
|
||||
//!
|
||||
//! IMPORTANT: On Android, pthread_create crashes in shared libraries due to
|
||||
//! static bionic stubs in the Rust std prebuilt rlibs. ALL work must happen
|
||||
//! on the JNI calling thread or via the tokio current_thread runtime.
|
||||
//! No std::thread::spawn or tokio multi_thread allowed.
|
||||
//!
|
||||
//! Audio capture and playout happen on Kotlin JVM threads via AudioRecord
|
||||
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tracing::{error, info, warn};
|
||||
use wzp_codec::agc::AutoGainControl;
|
||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::{
|
||||
AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||
MediaHeader, MediaPacket, MediaTransport, QualityProfile, SignalMessage,
|
||||
};
|
||||
|
||||
use crate::audio_ring::AudioRing;
|
||||
use crate::commands::EngineCommand;
|
||||
use crate::stats::{CallState, CallStats};
|
||||
|
||||
/// Max frame size at 48kHz mono (40ms = 1920 samples, for Codec2/Opus6k).
|
||||
const MAX_FRAME_SAMPLES: usize = 1920;
|
||||
|
||||
/// Compute frame samples at 48kHz for a given profile.
|
||||
fn frame_samples_for(profile: &QualityProfile) -> usize {
|
||||
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
|
||||
}
|
||||
|
||||
/// Configuration to start a call.
|
||||
pub struct CallStartConfig {
|
||||
pub profile: QualityProfile,
|
||||
/// When true, use the relay's chosen_profile from CallAnswer instead of local profile.
|
||||
pub auto_profile: bool,
|
||||
pub relay_addr: String,
|
||||
pub room: String,
|
||||
pub auth_token: Vec<u8>,
|
||||
pub identity_seed: [u8; 32],
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CallStartConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: QualityProfile::GOOD,
|
||||
auto_profile: false,
|
||||
relay_addr: String::new(),
|
||||
room: String::new(),
|
||||
auth_token: Vec::new(),
|
||||
identity_seed: [0u8; 32],
|
||||
alias: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EngineState {
|
||||
pub running: AtomicBool,
|
||||
pub muted: AtomicBool,
|
||||
pub stats: Mutex<CallStats>,
|
||||
pub command_tx: std::sync::mpsc::Sender<EngineCommand>,
|
||||
pub command_rx: Mutex<Option<std::sync::mpsc::Receiver<EngineCommand>>>,
|
||||
/// Ring buffer: Kotlin AudioRecord → Rust encoder
|
||||
pub capture_ring: AudioRing,
|
||||
/// Ring buffer: Rust decoder → Kotlin AudioTrack
|
||||
pub playout_ring: AudioRing,
|
||||
/// Current audio level (RMS) for UI display, updated by capture path.
|
||||
pub audio_level_rms: AtomicU32,
|
||||
/// QUIC transport handle — stored so stop_call() can close it immediately,
|
||||
/// triggering relay-side leave + RoomUpdate broadcast.
|
||||
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
|
||||
}
|
||||
|
||||
pub struct WzpEngine {
|
||||
pub(crate) state: Arc<EngineState>,
|
||||
tokio_runtime: Option<tokio::runtime::Runtime>,
|
||||
call_start: Option<Instant>,
|
||||
}
|
||||
|
||||
impl WzpEngine {
|
||||
pub fn new() -> Self {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let state = Arc::new(EngineState {
|
||||
running: AtomicBool::new(false),
|
||||
muted: AtomicBool::new(false),
|
||||
stats: Mutex::new(CallStats::default()),
|
||||
command_tx: tx,
|
||||
command_rx: Mutex::new(Some(rx)),
|
||||
capture_ring: AudioRing::new(),
|
||||
playout_ring: AudioRing::new(),
|
||||
audio_level_rms: AtomicU32::new(0),
|
||||
quic_transport: Mutex::new(None),
|
||||
});
|
||||
Self {
|
||||
state,
|
||||
tokio_runtime: None,
|
||||
call_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_call(&mut self, config: CallStartConfig) -> Result<(), anyhow::Error> {
|
||||
if self.state.running.load(Ordering::Acquire) {
|
||||
return Err(anyhow::anyhow!("call already active"));
|
||||
}
|
||||
|
||||
{
|
||||
let mut stats = self.state.stats.lock().unwrap();
|
||||
*stats = CallStats {
|
||||
state: CallState::Connecting,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| {
|
||||
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr)
|
||||
})?;
|
||||
|
||||
let room = config.room.clone();
|
||||
let identity_seed = config.identity_seed;
|
||||
let profile = config.profile;
|
||||
let auto_profile = config.auto_profile;
|
||||
let alias = config.alias.clone();
|
||||
let state = self.state.clone();
|
||||
|
||||
self.state.running.store(true, Ordering::Release);
|
||||
self.call_start = Some(Instant::now());
|
||||
|
||||
let state_clone = state.clone();
|
||||
runtime.block_on(async move {
|
||||
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, auto_profile, alias.as_deref(), state_clone).await
|
||||
{
|
||||
error!("call failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
state.running.store(false, Ordering::Release);
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Closed;
|
||||
}
|
||||
|
||||
self.tokio_runtime = Some(runtime);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_call(&mut self) {
|
||||
info!("stop_call: setting running=false");
|
||||
self.state.running.store(false, Ordering::Release);
|
||||
// Close QUIC connection — this wakes up all blocked recv/send futures
|
||||
// inside block_on(run_call(...)) on the JNI thread. run_call will then
|
||||
// wait up to 500ms for the peer to acknowledge the close before returning.
|
||||
if let Some(transport) = self.state.quic_transport.lock().unwrap().take() {
|
||||
info!("stop_call: closing QUIC connection");
|
||||
transport.close_now();
|
||||
}
|
||||
let _ = self.state.command_tx.send(EngineCommand::Stop);
|
||||
// Note: the runtime is still blocked in block_on(run_call(...)) on the
|
||||
// start_call thread. Once run_call exits (triggered by running=false +
|
||||
// connection close above), block_on returns and stores the runtime in
|
||||
// self.tokio_runtime. We don't need to shut it down here.
|
||||
if let Some(rt) = self.tokio_runtime.take() {
|
||||
rt.shutdown_timeout(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.call_start = None;
|
||||
info!("stop_call: done");
|
||||
}
|
||||
|
||||
/// Ping a relay — same pattern as start_call (creates runtime on calling thread).
|
||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
||||
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
||||
let addr: SocketAddr = address.parse()?;
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let result = rt.block_on(async {
|
||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = wzp_transport::create_endpoint(bind, None)?;
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let start = Instant::now();
|
||||
|
||||
let conn_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Always close endpoint to prevent resource leaks
|
||||
endpoint.close(0u32.into(), b"done");
|
||||
|
||||
let conn = conn_result.map_err(|_| anyhow::anyhow!("timeout"))??;
|
||||
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||
let server_fp = conn
|
||||
.peer_identity()
|
||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||
.and_then(|certs| certs.first().map(|c| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
c.as_ref().hash(&mut h);
|
||||
format!("{:016x}", h.finish())
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
conn.close(0u32.into(), b"ping");
|
||||
|
||||
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp))
|
||||
});
|
||||
|
||||
// Shutdown runtime cleanly with timeout
|
||||
rt.shutdown_timeout(std::time::Duration::from_millis(500));
|
||||
result
|
||||
}
|
||||
|
||||
pub fn set_mute(&self, muted: bool) {
|
||||
self.state.muted.store(muted, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_speaker(&self, _enabled: bool) {}
|
||||
|
||||
pub fn force_profile(&self, _profile: QualityProfile) {}
|
||||
|
||||
pub fn get_stats(&self) -> CallStats {
|
||||
let mut stats = self.state.stats.lock().unwrap().clone();
|
||||
if let Some(start) = self.call_start {
|
||||
stats.duration_secs = start.elapsed().as_secs_f64();
|
||||
}
|
||||
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
|
||||
stats.playout_overflows = self.state.playout_ring.overflow_count();
|
||||
stats.playout_underruns = self.state.playout_ring.underrun_count();
|
||||
stats.capture_overflows = self.state.capture_ring.overflow_count();
|
||||
stats
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.running.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn write_audio(&self, samples: &[i16]) -> usize {
|
||||
if self.state.muted.load(Ordering::Relaxed) {
|
||||
return samples.len();
|
||||
}
|
||||
// Compute RMS for audio level display
|
||||
if !samples.is_empty() {
|
||||
let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||
let rms = (sum_sq / samples.len() as f64).sqrt() as u32;
|
||||
self.state.audio_level_rms.store(rms, Ordering::Relaxed);
|
||||
}
|
||||
self.state.capture_ring.write(samples)
|
||||
}
|
||||
|
||||
pub fn read_audio(&self, out: &mut [i16]) -> usize {
|
||||
self.state.playout_ring.read(out)
|
||||
}
|
||||
|
||||
pub fn destroy(mut self) {
|
||||
self.stop_call();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WzpEngine {
|
||||
fn drop(&mut self) {
|
||||
self.stop_call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the full call lifecycle: connect, handshake, send/recv media with Opus + FEC.
|
||||
async fn run_call(
|
||||
relay_addr: SocketAddr,
|
||||
room: &str,
|
||||
identity_seed: &[u8; 32],
|
||||
profile: QualityProfile,
|
||||
auto_profile: bool,
|
||||
alias: Option<&str>,
|
||||
state: Arc<EngineState>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
|
||||
let sni = if room.is_empty() { "android" } else { room };
|
||||
info!(%relay_addr, sni, "connecting to relay...");
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let conn = wzp_transport::connect(&endpoint, relay_addr, sni, client_cfg).await?;
|
||||
info!("QUIC connected to relay");
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Store transport handle so stop_call() can close the connection immediately
|
||||
*state.quic_transport.lock().unwrap() = Some(transport.clone());
|
||||
|
||||
// Crypto handshake
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(identity_seed);
|
||||
let ephemeral_pub = kx.generate_ephemeral();
|
||||
let identity_pub = kx.identity_public_key();
|
||||
|
||||
let mut sign_data = Vec::with_capacity(42);
|
||||
sign_data.extend_from_slice(&ephemeral_pub);
|
||||
sign_data.extend_from_slice(b"call-offer");
|
||||
let signature = kx.sign(&sign_data);
|
||||
|
||||
let offer = SignalMessage::CallOffer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::STUDIO_64K,
|
||||
QualityProfile::STUDIO_48K,
|
||||
QualityProfile::STUDIO_32K,
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
alias: alias.map(|s| s.to_string()),
|
||||
};
|
||||
transport.send_signal(&offer).await?;
|
||||
info!("CallOffer sent, waiting for CallAnswer...");
|
||||
|
||||
let answer = transport
|
||||
.recv_signal()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
||||
|
||||
let (relay_ephemeral_pub, chosen_profile) = match answer {
|
||||
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallAnswer, got {:?}",
|
||||
std::mem::discriminant(&other)
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Auto mode: use the relay's chosen profile instead of the local preference
|
||||
let profile = if auto_profile {
|
||||
info!(chosen = ?chosen_profile.codec, "auto mode: using relay's chosen profile");
|
||||
chosen_profile
|
||||
} else {
|
||||
profile
|
||||
};
|
||||
|
||||
let _session = kx.derive_session(&relay_ephemeral_pub)?;
|
||||
info!(codec = ?profile.codec, "handshake complete, call active");
|
||||
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.state = CallState::Active;
|
||||
}
|
||||
|
||||
// Initialize codec (Opus or Codec2 based on profile)
|
||||
let mut encoder = wzp_codec::create_encoder(profile);
|
||||
let mut decoder = wzp_codec::create_decoder(profile);
|
||||
|
||||
// Initialize FEC encoder/decoder
|
||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||
let mut fec_dec = wzp_fec::create_decoder(&profile);
|
||||
|
||||
// AGC: normalize volume on both capture and playout paths
|
||||
let mut capture_agc = AutoGainControl::new();
|
||||
let mut playout_agc = AutoGainControl::new();
|
||||
|
||||
let frame_samples = frame_samples_for(&profile);
|
||||
info!(
|
||||
codec = ?profile.codec,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
frames_per_block = profile.frames_per_block,
|
||||
frame_ms = profile.frame_duration_ms,
|
||||
frame_samples,
|
||||
"codec + FEC + AGC initialized"
|
||||
);
|
||||
|
||||
let seq = AtomicU16::new(0);
|
||||
let ts = AtomicU32::new(0);
|
||||
let transport_recv = transport.clone();
|
||||
|
||||
// Pre-allocate buffers (sized for current profile)
|
||||
let mut capture_buf = vec![0i16; frame_samples];
|
||||
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
||||
let mut frame_in_block: u8 = 0;
|
||||
let mut block_id: u8 = 0;
|
||||
|
||||
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
||||
//
|
||||
// IMPORTANT: send_media() uses quinn's send_datagram() which is
|
||||
// synchronous and returns Err(Blocked) when the congestion window
|
||||
// is full. We MUST NOT break on send errors — that would kill the
|
||||
// entire call. Instead we drop the packet and keep going.
|
||||
let send_task = async {
|
||||
info!("send task started (Opus + RaptorQ FEC)");
|
||||
let mut send_errors: u64 = 0;
|
||||
let mut last_send_error_log = Instant::now();
|
||||
let mut last_stats_log = Instant::now();
|
||||
let mut frames_sent: u64 = 0;
|
||||
let mut frames_dropped: u64 = 0;
|
||||
// Per-step timing accumulators (reset every stats log)
|
||||
let mut t_agc_us: u64 = 0;
|
||||
let mut t_opus_us: u64 = 0;
|
||||
let mut t_fec_us: u64 = 0;
|
||||
let mut t_send_us: u64 = 0;
|
||||
let mut t_frames: u64 = 0;
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let avail = state.capture_ring.available();
|
||||
if avail < frame_samples {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let read = state.capture_ring.read(&mut capture_buf);
|
||||
if read < frame_samples {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mute: zero out the buffer so Opus encodes silence.
|
||||
// We still read from the ring to prevent it from filling up.
|
||||
if state.muted.load(Ordering::Relaxed) {
|
||||
capture_buf.fill(0);
|
||||
}
|
||||
|
||||
// AGC: normalize capture volume before encoding
|
||||
let t0 = Instant::now();
|
||||
capture_agc.process_frame(&mut capture_buf);
|
||||
t_agc_us += t0.elapsed().as_micros() as u64;
|
||||
|
||||
// Opus encode
|
||||
let t0 = Instant::now();
|
||||
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
warn!("opus encode error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
t_opus_us += t0.elapsed().as_micros() as u64;
|
||||
let encoded = &encode_buf[..encoded_len];
|
||||
|
||||
// Build source packet
|
||||
let s = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let t = ts.fetch_add(frame_samples as u32, Ordering::Relaxed);
|
||||
|
||||
let source_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: false,
|
||||
codec_id: profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(profile.fec_ratio),
|
||||
seq: s,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: frame_in_block,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::copy_from_slice(encoded),
|
||||
quality_report: None,
|
||||
};
|
||||
|
||||
// Send source packet — drop on error, never break
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Log first few errors, then throttle to once per second
|
||||
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
|
||||
warn!(
|
||||
seq = s,
|
||||
send_errors,
|
||||
frames_dropped,
|
||||
"send_media error (dropping packet): {e}"
|
||||
);
|
||||
last_send_error_log = Instant::now();
|
||||
}
|
||||
// Don't feed to FEC either — the source is lost
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
continue;
|
||||
}
|
||||
t_send_us += t0.elapsed().as_micros() as u64;
|
||||
frames_sent += 1;
|
||||
|
||||
// Feed encoded frame to FEC encoder
|
||||
let t0 = Instant::now();
|
||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||
warn!("fec add_source error: {e}");
|
||||
}
|
||||
frame_in_block += 1;
|
||||
|
||||
// When block is full, generate repair packets
|
||||
if frame_in_block >= profile.frames_per_block {
|
||||
match fec_enc.generate_repair(profile.fec_ratio) {
|
||||
Ok(repairs) => {
|
||||
let repair_count = repairs.len();
|
||||
for (sym_idx, repair_data) in repairs {
|
||||
let rs = seq.fetch_add(1, Ordering::Relaxed);
|
||||
let repair_pkt = MediaPacket {
|
||||
header: MediaHeader {
|
||||
version: 0,
|
||||
is_repair: true,
|
||||
codec_id: profile.codec,
|
||||
has_quality_report: false,
|
||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||
profile.fec_ratio,
|
||||
),
|
||||
seq: rs,
|
||||
timestamp: t,
|
||||
fec_block: block_id,
|
||||
fec_symbol: sym_idx,
|
||||
reserved: 0,
|
||||
csrc_count: 0,
|
||||
},
|
||||
payload: Bytes::from(repair_data),
|
||||
quality_report: None,
|
||||
};
|
||||
// Drop repair packets on error — never break
|
||||
if let Err(_e) = transport.send_media(&repair_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
// Don't log every repair failure — source error log covers it
|
||||
}
|
||||
}
|
||||
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
|
||||
info!(
|
||||
block_id,
|
||||
repair_count,
|
||||
fec_ratio = profile.fec_ratio,
|
||||
"FEC block complete"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("fec generate_repair error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fec_enc.finalize_block();
|
||||
block_id = block_id.wrapping_add(1);
|
||||
frame_in_block = 0;
|
||||
}
|
||||
t_fec_us += t0.elapsed().as_micros() as u64;
|
||||
t_frames += 1;
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_stats_log.elapsed().as_secs() >= 5 {
|
||||
let avg = |total: u64| if t_frames > 0 { total / t_frames } else { 0 };
|
||||
info!(
|
||||
seq = s,
|
||||
block_id,
|
||||
frames_sent,
|
||||
frames_dropped,
|
||||
send_errors,
|
||||
ring_avail = state.capture_ring.available(),
|
||||
capture_overflows = state.capture_ring.overflow_count(),
|
||||
avg_agc_us = avg(t_agc_us),
|
||||
avg_opus_us = avg(t_opus_us),
|
||||
avg_fec_us = avg(t_fec_us),
|
||||
avg_send_us = avg(t_send_us),
|
||||
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
|
||||
"send stats"
|
||||
);
|
||||
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
|
||||
last_stats_log = Instant::now();
|
||||
}
|
||||
}
|
||||
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
||||
};
|
||||
|
||||
// Pre-allocate decode buffer (max size to handle any incoming codec)
|
||||
let mut decode_buf = vec![0i16; MAX_FRAME_SAMPLES];
|
||||
|
||||
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
||||
let recv_task = async {
|
||||
let mut frames_decoded: u64 = 0;
|
||||
let mut fec_recovered: u64 = 0;
|
||||
let mut recv_errors: u64 = 0;
|
||||
let mut last_recv_instant = Instant::now();
|
||||
let mut max_recv_gap_ms: u64 = 0;
|
||||
let mut last_stats_log = Instant::now();
|
||||
info!("recv task started (Opus + RaptorQ FEC)");
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match transport_recv.recv_media().await {
|
||||
Ok(Some(pkt)) => {
|
||||
// Track recv gaps — large gaps indicate network or relay issues
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
if recv_gap_ms > 500 {
|
||||
warn!(
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
is_repair = pkt.header.is_repair,
|
||||
"large recv gap — possible network stall"
|
||||
);
|
||||
}
|
||||
|
||||
let is_repair = pkt.header.is_repair;
|
||||
let pkt_block = pkt.header.fec_block;
|
||||
let pkt_symbol = pkt.header.fec_symbol;
|
||||
|
||||
// Feed every packet (source + repair) to FEC decoder
|
||||
let _ = fec_dec.add_symbol(
|
||||
pkt_block,
|
||||
pkt_symbol,
|
||||
is_repair,
|
||||
&pkt.payload,
|
||||
);
|
||||
|
||||
// Source packets: decode directly
|
||||
if !is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||
// Switch decoder to match incoming codec if different
|
||||
if pkt.header.codec_id != decoder.codec_id() {
|
||||
let switch_profile = match pkt.header.codec_id {
|
||||
CodecId::Opus24k => QualityProfile::GOOD,
|
||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||
CodecId::Codec2_3200 => QualityProfile {
|
||||
codec: CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
||||
};
|
||||
info!(from = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder");
|
||||
let _ = decoder.set_profile(switch_profile);
|
||||
}
|
||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||
Ok(samples) => {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
frames_decoded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("opus decode error: {e}");
|
||||
if let Ok(samples) = decoder.decode_lost(&mut decode_buf) {
|
||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||
state.playout_ring.write(&decode_buf[..samples]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try FEC recovery
|
||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||
fec_recovered += recovered_frames.len() as u64;
|
||||
if fec_recovered % 50 == 1 {
|
||||
info!(
|
||||
fec_recovered,
|
||||
block = pkt_block,
|
||||
frames = recovered_frames.len(),
|
||||
"FEC block recovered"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old blocks to prevent memory growth
|
||||
if pkt_block > 3 {
|
||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||
}
|
||||
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_decoded = frames_decoded;
|
||||
stats.fec_recovered = fec_recovered;
|
||||
drop(stats);
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_stats_log.elapsed().as_secs() >= 5 {
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
playout_overflows = state.playout_ring.overflow_count(),
|
||||
playout_underruns = state.playout_ring.underrun_count(),
|
||||
"recv stats"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
last_stats_log = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
recv_errors += 1;
|
||||
// Transient errors: log and keep going
|
||||
let msg = e.to_string();
|
||||
if msg.contains("closed") || msg.contains("reset") {
|
||||
error!(recv_errors, "recv fatal: {e}");
|
||||
break;
|
||||
}
|
||||
// Non-fatal: log throttled
|
||||
if recv_errors <= 3 || recv_errors % 50 == 0 {
|
||||
warn!(recv_errors, "recv error (continuing): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
|
||||
};
|
||||
|
||||
// Stats task — polls path quality + quinn RTT every 500ms
|
||||
let transport_stats = transport.clone();
|
||||
let stats_task = async {
|
||||
loop {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
// Feed quinn's QUIC-level RTT into our path monitor
|
||||
let quic_rtt_ms = transport_stats.connection().stats().path.rtt.as_millis() as u32;
|
||||
if quic_rtt_ms > 0 {
|
||||
transport_stats.feed_rtt(quic_rtt_ms);
|
||||
}
|
||||
let pq = transport_stats.path_quality();
|
||||
{
|
||||
let mut stats = state.stats.lock().unwrap();
|
||||
stats.frames_encoded = seq.load(Ordering::Relaxed) as u64;
|
||||
stats.loss_pct = pq.loss_pct;
|
||||
stats.rtt_ms = quic_rtt_ms;
|
||||
stats.jitter_ms = pq.jitter_ms;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
};
|
||||
|
||||
// Signal recv task — listens for RoomUpdate and other signaling messages
|
||||
let transport_signal = transport.clone();
|
||||
let state_signal = state.clone();
|
||||
let signal_task = async {
|
||||
loop {
|
||||
match transport_signal.recv_signal().await {
|
||||
Ok(Some(SignalMessage::RoomUpdate { count, participants })) => {
|
||||
info!(count, "RoomUpdate received");
|
||||
let members: Vec<crate::stats::RoomMember> = participants
|
||||
.iter()
|
||||
.map(|p| crate::stats::RoomMember {
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
alias: p.alias.clone(),
|
||||
})
|
||||
.collect();
|
||||
let mut stats = state_signal.stats.lock().unwrap();
|
||||
stats.room_participant_count = count;
|
||||
stats.room_participants = members;
|
||||
}
|
||||
Ok(Some(msg)) => {
|
||||
info!("signal received: {:?}", std::mem::discriminant(&msg));
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("signal stream closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => info!("send task ended"),
|
||||
_ = recv_task => info!("recv task ended"),
|
||||
_ = stats_task => info!("stats task ended"),
|
||||
_ = signal_task => info!("signal task ended"),
|
||||
}
|
||||
|
||||
// Send CONNECTION_CLOSE and wait up to 500ms for the peer to acknowledge.
|
||||
// This ensures the relay sees the close even if the first packet is lost.
|
||||
info!("closing QUIC connection...");
|
||||
transport.close_now();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
transport.connection().closed(),
|
||||
).await {
|
||||
Ok(_) => info!("QUIC connection closed cleanly"),
|
||||
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
361
crates/wzp-android/src/jni_bridge.rs
Normal file
361
crates/wzp-android/src/jni_bridge.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
|
||||
|
||||
use std::panic;
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
use tracing::{error, info};
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
use crate::engine::{CallStartConfig, WzpEngine};
|
||||
|
||||
/// Opaque engine handle passed to/from Kotlin as a `jlong`.
|
||||
struct EngineHandle {
|
||||
engine: WzpEngine,
|
||||
}
|
||||
|
||||
/// Recover the `EngineHandle` from a raw handle value.
|
||||
unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
|
||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||
}
|
||||
|
||||
/// 7 = auto (use relay's chosen profile)
|
||||
const PROFILE_AUTO: jint = 7;
|
||||
|
||||
fn profile_from_int(value: jint) -> QualityProfile {
|
||||
match value {
|
||||
0 => QualityProfile::GOOD, // Opus 24k
|
||||
1 => QualityProfile::DEGRADED, // Opus 6k
|
||||
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
||||
3 => QualityProfile { // Codec2 3.2k
|
||||
codec: wzp_proto::CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
4 => QualityProfile::STUDIO_32K, // Opus 32k
|
||||
5 => QualityProfile::STUDIO_48K, // Opus 48k
|
||||
6 => QualityProfile::STUDIO_64K, // Opus 64k
|
||||
_ => QualityProfile::GOOD, // auto falls back to GOOD
|
||||
}
|
||||
}
|
||||
|
||||
static INIT_LOGGING: Once = Once::new();
|
||||
|
||||
/// Initialize tracing → Android logcat (tag "wzp_android").
|
||||
/// Safe to call multiple times — only the first call takes effect.
|
||||
fn init_logging() {
|
||||
INIT_LOGGING.call_once(|| {
|
||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
||||
// tracing_subscriber::registry() can crash on some Android
|
||||
// devices if scudo malloc fails during early initialization.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
||||
// Filter: INFO for our crates, WARN for everything else.
|
||||
// The jni crate emits VERBOSE logs for every method lookup
|
||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
||||
// and causes the system to kill the app.
|
||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(layer)
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jlong {
|
||||
let result = panic::catch_unwind(|| {
|
||||
init_logging();
|
||||
let handle = Box::new(EngineHandle {
|
||||
engine: WzpEngine::new(),
|
||||
});
|
||||
Box::into_raw(handle) as jlong
|
||||
});
|
||||
match result {
|
||||
Ok(h) => h,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
relay_addr_j: JString,
|
||||
room_j: JString,
|
||||
seed_hex_j: JString,
|
||||
token_j: JString,
|
||||
alias_j: JString,
|
||||
profile_j: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default();
|
||||
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
|
||||
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
|
||||
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
|
||||
// Parse hex seed
|
||||
let mut identity_seed = [0u8; 32];
|
||||
if seed_hex.len() == 64 {
|
||||
for i in 0..32 {
|
||||
if let Ok(byte) = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) {
|
||||
identity_seed[i] = byte;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generate random seed if not provided
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut identity_seed);
|
||||
}
|
||||
|
||||
let config = CallStartConfig {
|
||||
profile: profile_from_int(profile_j),
|
||||
auto_profile: profile_j == PROFILE_AUTO,
|
||||
relay_addr,
|
||||
room,
|
||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||
identity_seed,
|
||||
alias: if alias.is_empty() { None } else { Some(alias) },
|
||||
};
|
||||
|
||||
match h.engine.start_call(config) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
error!("start_call failed: {e}");
|
||||
-1
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
match result {
|
||||
Ok(code) => code,
|
||||
Err(_) => -1,
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine.stop_call();
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
muted: jboolean,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine.set_mute(muted != 0);
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
speaker: jboolean,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
h.engine.set_speaker(speaker != 0);
|
||||
}));
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let stats = h.engine.get_stats();
|
||||
serde_json::to_string(&stats).unwrap_or_else(|_| "{}".to_string())
|
||||
}));
|
||||
|
||||
let json = match result {
|
||||
Ok(s) => s,
|
||||
Err(_) => "{}".to_string(),
|
||||
};
|
||||
|
||||
env.new_string(&json)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
profile: jint,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let qp = profile_from_int(profile);
|
||||
h.engine.force_profile(qp);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
|
||||
/// pcm is a Java short[] array.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
pcm: jni::objects::JShortArray,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let len = env.get_array_length(&pcm).unwrap_or(0) as usize;
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut buf = vec![0i16; len];
|
||||
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
|
||||
return 0;
|
||||
}
|
||||
h.engine.write_audio(&buf) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read decoded PCM samples from the engine's playout ring for Kotlin AudioTrack.
|
||||
/// pcm is a Java short[] array to fill. Returns number of samples actually read.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
pcm: jni::objects::JShortArray,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let len = env.get_array_length(&pcm).unwrap_or(0) as usize;
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut buf = vec![0i16; len];
|
||||
let read = h.engine.read_audio(&mut buf);
|
||||
if read > 0 {
|
||||
let _ = env.set_short_array_region(&pcm, 0, &buf[..read]);
|
||||
}
|
||||
read as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
|
||||
/// The ByteBuffer must contain little-endian i16 samples.
|
||||
/// Called from the AudioRecord capture thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
buffer: jni::objects::JByteBuffer,
|
||||
sample_count: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || sample_count <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
|
||||
};
|
||||
h.engine.write_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
|
||||
/// The ByteBuffer will be filled with little-endian i16 samples.
|
||||
/// Called from the AudioTrack playout thread.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
buffer: jni::objects::JByteBuffer,
|
||||
max_samples: jint,
|
||||
) -> jint {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
|
||||
if ptr.is_null() || max_samples <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let samples = unsafe {
|
||||
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
|
||||
};
|
||||
h.engine.read_audio(samples) as jint
|
||||
}));
|
||||
result.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) {
|
||||
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { Box::from_raw(handle as *mut EngineHandle) };
|
||||
drop(h);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Ping a relay server — instance method, requires engine handle.
|
||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||
mut env: JNIEnv<'a>,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
relay_j: JString,
|
||||
) -> jstring {
|
||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let h = unsafe { handle_ref(handle) };
|
||||
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||
match h.engine.ping_relay(&relay) {
|
||||
Ok(json) => Some(json),
|
||||
Err(_) => None,
|
||||
}
|
||||
}));
|
||||
|
||||
let json = match result {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return JObject::null().into_raw(),
|
||||
};
|
||||
env.new_string(&json)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(JObject::null().into_raw())
|
||||
}
|
||||
18
crates/wzp-android/src/lib.rs
Normal file
18
crates/wzp-android/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! WarzonePhone Android native VoIP engine.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - Oboe audio backend with lock-free SPSC ring buffers
|
||||
//! - Engine orchestrator managing call lifecycle
|
||||
//! - Codec pipeline thread (encode/decode/FEC/jitter)
|
||||
//! - Call statistics and command interface
|
||||
//!
|
||||
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
||||
//! allowing `cargo check` and unit tests on the host.
|
||||
|
||||
pub mod audio_android;
|
||||
pub mod audio_ring;
|
||||
pub mod commands;
|
||||
pub mod engine;
|
||||
pub mod pipeline;
|
||||
pub mod stats;
|
||||
pub mod jni_bridge;
|
||||
262
crates/wzp-android/src/pipeline.rs
Normal file
262
crates/wzp-android/src/pipeline.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
//! Codec pipeline — encode/decode with FEC and jitter buffer.
|
||||
//!
|
||||
//! Runs on a dedicated thread, processing 20 ms frames at 48 kHz.
|
||||
//! The pipeline is NOT Send/Sync (Opus encoder state) — it is owned
|
||||
//! exclusively by the codec thread.
|
||||
|
||||
use tracing::{debug, warn};
|
||||
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::quality::AdaptiveQualityController;
|
||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
||||
use wzp_proto::traits::QualityController;
|
||||
use wzp_proto::{MediaPacket, QualityProfile};
|
||||
|
||||
use crate::audio_android::FRAME_SAMPLES;
|
||||
|
||||
/// Maximum encoded frame size (Opus worst case at highest bitrate).
|
||||
const MAX_ENCODED_BYTES: usize = 1275;
|
||||
|
||||
/// Pipeline statistics snapshot.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PipelineStats {
|
||||
pub frames_encoded: u64,
|
||||
pub frames_decoded: u64,
|
||||
pub underruns: u64,
|
||||
pub jitter_depth: usize,
|
||||
pub quality_tier: u8,
|
||||
}
|
||||
|
||||
/// The codec pipeline: encode, FEC, jitter buffer, decode.
|
||||
///
|
||||
/// This struct is owned by the codec thread and not shared.
|
||||
pub struct Pipeline {
|
||||
encoder: AdaptiveEncoder,
|
||||
decoder: AdaptiveDecoder,
|
||||
fec_encoder: RaptorQFecEncoder,
|
||||
fec_decoder: RaptorQFecDecoder,
|
||||
jitter_buffer: JitterBuffer,
|
||||
quality_ctrl: AdaptiveQualityController,
|
||||
/// Acoustic echo canceller applied before encoding.
|
||||
aec: EchoCanceller,
|
||||
/// Automatic gain control applied before encoding.
|
||||
agc: AutoGainControl,
|
||||
/// Last decoded PCM frame, used as the AEC far-end reference.
|
||||
last_decoded_farend: Option<Vec<i16>>,
|
||||
// Pre-allocated scratch buffers
|
||||
capture_buf: Vec<i16>,
|
||||
#[allow(dead_code)]
|
||||
playout_buf: Vec<i16>,
|
||||
encode_out: Vec<u8>,
|
||||
// Stats counters
|
||||
frames_encoded: u64,
|
||||
frames_decoded: u64,
|
||||
underruns: u64,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Create a new pipeline configured for the given quality profile.
|
||||
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
|
||||
let encoder = AdaptiveEncoder::new(profile)
|
||||
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
|
||||
let decoder = AdaptiveDecoder::new(profile)
|
||||
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
|
||||
let fec_encoder =
|
||||
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
|
||||
let fec_decoder =
|
||||
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
|
||||
let jitter_buffer = JitterBuffer::new(10, 250, 3);
|
||||
let quality_ctrl = AdaptiveQualityController::new();
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
decoder,
|
||||
fec_encoder,
|
||||
fec_decoder,
|
||||
jitter_buffer,
|
||||
quality_ctrl,
|
||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||
agc: AutoGainControl::new(),
|
||||
last_decoded_farend: None,
|
||||
capture_buf: vec![0i16; FRAME_SAMPLES],
|
||||
playout_buf: vec![0i16; FRAME_SAMPLES],
|
||||
encode_out: vec![0u8; MAX_ENCODED_BYTES],
|
||||
frames_encoded: 0,
|
||||
frames_decoded: 0,
|
||||
underruns: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a PCM frame into a compressed packet.
|
||||
///
|
||||
/// If `muted` is true, a silence frame is encoded (all zeros).
|
||||
/// Returns the encoded bytes, or `None` on encoder error.
|
||||
pub fn encode_frame(&mut self, pcm: &[i16], muted: bool) -> Option<Vec<u8>> {
|
||||
let input = if muted {
|
||||
// Zero the capture buffer for silence
|
||||
for s in self.capture_buf.iter_mut() {
|
||||
*s = 0;
|
||||
}
|
||||
&self.capture_buf[..]
|
||||
} else {
|
||||
// Feed the last decoded playout as AEC far-end reference.
|
||||
if let Some(ref farend) = self.last_decoded_farend {
|
||||
self.aec.feed_farend(farend);
|
||||
}
|
||||
|
||||
// Apply AEC + AGC to the captured PCM.
|
||||
let len = pcm.len().min(self.capture_buf.len());
|
||||
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
|
||||
self.aec.process_frame(&mut self.capture_buf[..len]);
|
||||
self.agc.process_frame(&mut self.capture_buf[..len]);
|
||||
&self.capture_buf[..len]
|
||||
};
|
||||
|
||||
match self.encoder.encode(input, &mut self.encode_out) {
|
||||
Ok(n) => {
|
||||
self.frames_encoded += 1;
|
||||
let encoded = self.encode_out[..n].to_vec();
|
||||
|
||||
// Feed into FEC encoder
|
||||
if let Err(e) = self.fec_encoder.add_source_symbol(&encoded) {
|
||||
warn!("FEC encode error: {e}");
|
||||
}
|
||||
|
||||
Some(encoded)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("encode error: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a received media packet into the jitter buffer.
|
||||
pub fn feed_packet(&mut self, packet: MediaPacket) {
|
||||
// Feed FEC symbols if present
|
||||
let header = &packet.header;
|
||||
if header.fec_block != 0 || header.fec_symbol != 0 {
|
||||
let is_repair = header.is_repair;
|
||||
if let Err(e) = self.fec_decoder.add_symbol(
|
||||
header.fec_block,
|
||||
header.fec_symbol,
|
||||
is_repair,
|
||||
&packet.payload,
|
||||
) {
|
||||
debug!("FEC symbol feed error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self.jitter_buffer.push(packet);
|
||||
}
|
||||
|
||||
/// Decode the next frame from the jitter buffer.
|
||||
///
|
||||
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
|
||||
/// Decoded PCM is also stored as the AEC far-end reference for the next
|
||||
/// encode cycle.
|
||||
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
|
||||
let result = match self.jitter_buffer.pop() {
|
||||
PlayoutResult::Packet(pkt) => {
|
||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||
match self.decoder.decode(&pkt.payload, &mut pcm) {
|
||||
Ok(n) => {
|
||||
self.frames_decoded += 1;
|
||||
pcm.truncate(n);
|
||||
Some(pcm)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("decode error: {e}");
|
||||
// Attempt PLC
|
||||
self.generate_plc()
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayoutResult::Missing { seq } => {
|
||||
debug!(seq, "jitter buffer: missing packet, generating PLC");
|
||||
self.generate_plc()
|
||||
}
|
||||
PlayoutResult::NotReady => {
|
||||
self.underruns += 1;
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Save decoded PCM as far-end reference for AEC.
|
||||
if let Some(ref pcm) = result {
|
||||
self.last_decoded_farend = Some(pcm.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Generate packet loss concealment output.
|
||||
fn generate_plc(&mut self) -> Option<Vec<i16>> {
|
||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||
match self.decoder.decode_lost(&mut pcm) {
|
||||
Ok(n) => {
|
||||
self.frames_decoded += 1;
|
||||
pcm.truncate(n);
|
||||
Some(pcm)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("PLC error: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a quality report into the adaptive quality controller.
|
||||
///
|
||||
/// Returns a new profile if a tier transition occurred.
|
||||
#[allow(unused)]
|
||||
pub fn observe_quality(
|
||||
&mut self,
|
||||
report: &wzp_proto::QualityReport,
|
||||
) -> Option<QualityProfile> {
|
||||
let new_profile = self.quality_ctrl.observe(report);
|
||||
if let Some(ref profile) = new_profile {
|
||||
if let Err(e) = self.encoder.set_profile(*profile) {
|
||||
warn!("encoder set_profile error: {e}");
|
||||
}
|
||||
if let Err(e) = self.decoder.set_profile(*profile) {
|
||||
warn!("decoder set_profile error: {e}");
|
||||
}
|
||||
}
|
||||
new_profile
|
||||
}
|
||||
|
||||
/// Force a specific quality profile.
|
||||
#[allow(unused)]
|
||||
pub fn force_profile(&mut self, profile: QualityProfile) {
|
||||
self.quality_ctrl.force_profile(profile);
|
||||
if let Err(e) = self.encoder.set_profile(profile) {
|
||||
warn!("encoder set_profile error: {e}");
|
||||
}
|
||||
if let Err(e) = self.decoder.set_profile(profile) {
|
||||
warn!("decoder set_profile error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current pipeline statistics.
|
||||
pub fn stats(&self) -> PipelineStats {
|
||||
PipelineStats {
|
||||
frames_encoded: self.frames_encoded,
|
||||
frames_decoded: self.frames_decoded,
|
||||
underruns: self.underruns,
|
||||
jitter_depth: self.jitter_buffer.stats().current_depth,
|
||||
quality_tier: self.quality_ctrl.tier() as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||
self.aec.set_enabled(enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable automatic gain control.
|
||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||
self.agc.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
73
crates/wzp-android/src/stats.rs
Normal file
73
crates/wzp-android/src/stats.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Call statistics for the Android engine.
|
||||
|
||||
/// State of the call.
|
||||
/// Serializes as integer for easy parsing on the Kotlin side:
|
||||
/// 0=Idle, 1=Connecting, 2=Active, 3=Reconnecting, 4=Closed
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CallState {
|
||||
#[default]
|
||||
Idle,
|
||||
Connecting,
|
||||
Active,
|
||||
Reconnecting,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl serde::Serialize for CallState {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let n: u8 = match self {
|
||||
CallState::Idle => 0,
|
||||
CallState::Connecting => 1,
|
||||
CallState::Active => 2,
|
||||
CallState::Reconnecting => 3,
|
||||
CallState::Closed => 4,
|
||||
};
|
||||
serializer.serialize_u8(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated call statistics, serializable for JNI bridge.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct CallStats {
|
||||
/// Current call state.
|
||||
pub state: CallState,
|
||||
/// Call duration in seconds.
|
||||
pub duration_secs: f64,
|
||||
/// Current quality tier (0=GOOD, 1=DEGRADED, 2=CATASTROPHIC).
|
||||
pub quality_tier: u8,
|
||||
/// Observed packet loss percentage.
|
||||
pub loss_pct: f32,
|
||||
/// Smoothed round-trip time in milliseconds.
|
||||
pub rtt_ms: u32,
|
||||
/// Jitter in milliseconds.
|
||||
pub jitter_ms: u32,
|
||||
/// Current jitter buffer depth in packets.
|
||||
pub jitter_buffer_depth: usize,
|
||||
/// Total frames encoded since call start.
|
||||
pub frames_encoded: u64,
|
||||
/// Total frames decoded since call start.
|
||||
pub frames_decoded: u64,
|
||||
/// Number of playout underruns (buffer empty when audio needed).
|
||||
pub underruns: u64,
|
||||
/// Frames recovered by FEC.
|
||||
pub fec_recovered: u64,
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
pub playout_underruns: u64,
|
||||
/// Capture ring overflow count.
|
||||
pub capture_overflows: u64,
|
||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||
pub audio_level: u32,
|
||||
/// Number of participants in the room (from last RoomUpdate).
|
||||
pub room_participant_count: u32,
|
||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
||||
pub room_participants: Vec<RoomMember>,
|
||||
}
|
||||
|
||||
/// A room member entry, serialized into the stats JSON.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct RoomMember {
|
||||
pub fingerprint: String,
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
|
||||
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||
@@ -207,6 +207,10 @@ pub struct CallEncoder {
|
||||
frame_in_block: u8,
|
||||
/// Timestamp counter (ms).
|
||||
timestamp_ms: u32,
|
||||
/// Acoustic echo canceller (removes speaker echo from mic signal).
|
||||
aec: EchoCanceller,
|
||||
/// Automatic gain control (normalises mic level).
|
||||
agc: AutoGainControl,
|
||||
/// Silence detector for suppression.
|
||||
silence_detector: SilenceDetector,
|
||||
/// Whether silence suppression is enabled.
|
||||
@@ -237,6 +241,8 @@ impl CallEncoder {
|
||||
block_id: 0,
|
||||
frame_in_block: 0,
|
||||
timestamp_ms: 0,
|
||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||
agc: AutoGainControl::new(),
|
||||
silence_detector: SilenceDetector::new(
|
||||
config.silence_threshold_rms,
|
||||
config.silence_hangover_frames,
|
||||
@@ -274,15 +280,21 @@ impl CallEncoder {
|
||||
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
|
||||
/// Output: one or more MediaPackets to send.
|
||||
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
|
||||
// Noise suppression: denoise the PCM before silence detection and encoding.
|
||||
let pcm = if self.denoiser.is_enabled() {
|
||||
let mut buf = pcm.to_vec();
|
||||
self.denoiser.process(&mut buf);
|
||||
buf
|
||||
} else {
|
||||
pcm.to_vec()
|
||||
};
|
||||
let pcm = &pcm[..];
|
||||
// Copy PCM into a mutable buffer for the processing pipeline.
|
||||
let mut pcm_buf = pcm.to_vec();
|
||||
|
||||
// Step 1: Echo cancellation (far-end reference must have been fed already).
|
||||
self.aec.process_frame(&mut pcm_buf);
|
||||
|
||||
// Step 2: Automatic gain control (normalise mic level).
|
||||
self.agc.process_frame(&mut pcm_buf);
|
||||
|
||||
// Step 3: Noise suppression (RNNoise).
|
||||
if self.denoiser.is_enabled() {
|
||||
self.denoiser.process(&mut pcm_buf);
|
||||
}
|
||||
|
||||
let pcm = &pcm_buf[..];
|
||||
|
||||
// Silence suppression: skip encoding silent frames, periodically send CN.
|
||||
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
|
||||
@@ -400,6 +412,24 @@ impl CallEncoder {
|
||||
self.frame_in_block = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Feed decoded playout audio as the echo reference signal.
|
||||
///
|
||||
/// Must be called with each decoded frame BEFORE the corresponding
|
||||
/// microphone frame is processed.
|
||||
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
|
||||
self.aec.feed_farend(farend);
|
||||
}
|
||||
|
||||
/// Enable or disable acoustic echo cancellation.
|
||||
pub fn set_aec_enabled(&mut self, enabled: bool) {
|
||||
self.aec.set_enabled(enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable automatic gain control.
|
||||
pub fn set_agc_enabled(&mut self, enabled: bool) {
|
||||
self.agc.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the recv/decode side of a call.
|
||||
|
||||
@@ -287,6 +287,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
None, // alias — desktop client doesn't set one yet
|
||||
).await?;
|
||||
info!("crypto handshake complete");
|
||||
|
||||
|
||||
@@ -109,12 +109,17 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||
SignalMessage::FederationRoomJoin { .. }
|
||||
| SignalMessage::FederationRoomLeave { .. }
|
||||
| SignalMessage::FederationParticipantUpdate { .. } => CallSignalType::Offer, // relay-only
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wzp_proto::QualityProfile;
|
||||
|
||||
#[test]
|
||||
fn payload_roundtrip() {
|
||||
@@ -123,6 +128,7 @@ mod tests {
|
||||
ephemeral_pub: [2u8; 32],
|
||||
signature: vec![3u8; 64],
|
||||
supported_profiles: vec![QualityProfile::GOOD],
|
||||
alias: None,
|
||||
};
|
||||
|
||||
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||
@@ -140,6 +146,7 @@ mod tests {
|
||||
ephemeral_pub: [0; 32],
|
||||
signature: vec![],
|
||||
supported_profiles: vec![],
|
||||
alias: None,
|
||||
};
|
||||
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
||||
pub async fn perform_handshake(
|
||||
transport: &dyn MediaTransport,
|
||||
seed: &[u8; 32],
|
||||
alias: Option<&str>,
|
||||
) -> Result<Box<dyn CryptoSession>, anyhow::Error> {
|
||||
// 1. Create key exchange from identity seed
|
||||
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
||||
@@ -37,10 +38,14 @@ pub async fn perform_handshake(
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles: vec![
|
||||
QualityProfile::STUDIO_64K,
|
||||
QualityProfile::STUDIO_48K,
|
||||
QualityProfile::STUDIO_32K,
|
||||
QualityProfile::GOOD,
|
||||
QualityProfile::DEGRADED,
|
||||
QualityProfile::CATASTROPHIC,
|
||||
],
|
||||
alias: alias.map(|s| s.to_string()),
|
||||
};
|
||||
transport.send_signal(&offer).await?;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::codec2_dec::Codec2Decoder;
|
||||
use crate::codec2_enc::Codec2Encoder;
|
||||
use crate::opus_dec::OpusDecoder;
|
||||
use crate::opus_enc::OpusEncoder;
|
||||
use crate::resample;
|
||||
use crate::resample::{Downsampler48to8, Upsampler8to48};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct AdaptiveEncoder {
|
||||
opus: OpusEncoder,
|
||||
codec2: Codec2Encoder,
|
||||
active: CodecId,
|
||||
downsampler: Downsampler48to8,
|
||||
}
|
||||
|
||||
impl AdaptiveEncoder {
|
||||
@@ -66,6 +67,7 @@ impl AdaptiveEncoder {
|
||||
opus,
|
||||
codec2,
|
||||
active: profile.codec,
|
||||
downsampler: Downsampler48to8::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,7 +76,7 @@ impl AudioEncoder for AdaptiveEncoder {
|
||||
fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result<usize, CodecError> {
|
||||
if is_codec2(self.active) {
|
||||
// Downsample 48 kHz → 8 kHz then encode via Codec2.
|
||||
let pcm_8k = resample::resample_48k_to_8k(pcm);
|
||||
let pcm_8k = self.downsampler.process(pcm);
|
||||
self.codec2.encode(&pcm_8k, out)
|
||||
} else {
|
||||
self.opus.encode(pcm, out)
|
||||
@@ -126,6 +128,7 @@ pub struct AdaptiveDecoder {
|
||||
opus: OpusDecoder,
|
||||
codec2: Codec2Decoder,
|
||||
active: CodecId,
|
||||
upsampler: Upsampler8to48,
|
||||
}
|
||||
|
||||
impl AdaptiveDecoder {
|
||||
@@ -138,6 +141,7 @@ impl AdaptiveDecoder {
|
||||
opus,
|
||||
codec2,
|
||||
active: profile.codec,
|
||||
upsampler: Upsampler8to48::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -149,7 +153,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
||||
let c2_samples = self.codec2_frame_samples();
|
||||
let mut buf_8k = vec![0i16; c2_samples];
|
||||
let n = self.codec2.decode(encoded, &mut buf_8k)?;
|
||||
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
||||
let out_len = pcm_48k.len().min(pcm.len());
|
||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||
Ok(out_len)
|
||||
@@ -163,7 +167,7 @@ impl AudioDecoder for AdaptiveDecoder {
|
||||
let c2_samples = self.codec2_frame_samples();
|
||||
let mut buf_8k = vec![0i16; c2_samples];
|
||||
let n = self.codec2.decode_lost(&mut buf_8k)?;
|
||||
let pcm_48k = resample::resample_8k_to_48k(&buf_8k[..n]);
|
||||
let pcm_48k = self.upsampler.process(&buf_8k[..n]);
|
||||
let out_len = pcm_48k.len().min(pcm.len());
|
||||
pcm[..out_len].copy_from_slice(&pcm_48k[..out_len]);
|
||||
Ok(out_len)
|
||||
|
||||
228
crates/wzp-codec/src/aec.rs
Normal file
228
crates/wzp-codec/src/aec.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
||||
|
||||
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
|
||||
///
|
||||
/// Removes acoustic echo by modelling the echo path between the far-end
|
||||
/// (speaker) signal and the near-end (microphone) signal, then subtracting
|
||||
/// the estimated echo from the near-end in real time.
|
||||
pub struct EchoCanceller {
|
||||
filter_coeffs: Vec<f32>,
|
||||
filter_len: usize,
|
||||
far_end_buf: Vec<f32>,
|
||||
far_end_pos: usize,
|
||||
mu: f32,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl EchoCanceller {
|
||||
/// Create a new echo canceller.
|
||||
///
|
||||
/// * `sample_rate` — typically 48000
|
||||
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||
Self {
|
||||
filter_coeffs: vec![0.0f32; filter_len],
|
||||
filter_len,
|
||||
far_end_buf: vec![0.0f32; filter_len],
|
||||
far_end_pos: 0,
|
||||
mu: 0.01,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
||||
///
|
||||
/// Must be called with the audio that was played out through the speaker
|
||||
/// *before* the corresponding near-end frame is processed.
|
||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||
for &s in farend {
|
||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||
///
|
||||
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
||||
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
||||
/// mean echo was reduced.
|
||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
||||
if !self.enabled {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let n = nearend.len();
|
||||
let fl = self.filter_len;
|
||||
|
||||
let mut sum_near_sq: f64 = 0.0;
|
||||
let mut sum_err_sq: f64 = 0.0;
|
||||
|
||||
for i in 0..n {
|
||||
let near_f = nearend[i] as f32;
|
||||
|
||||
// --- estimate echo as dot(coeffs, farend_window) ---
|
||||
// The far-end window for this sample starts at
|
||||
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
||||
// and goes back filter_len samples.
|
||||
let mut echo_est: f32 = 0.0;
|
||||
let mut power: f32 = 0.0;
|
||||
|
||||
// Position of the most-recent far-end sample for this near-end sample.
|
||||
// far_end_pos points to the *next write* position, so the most-recent
|
||||
// sample written is at far_end_pos - 1. We have already called
|
||||
// feed_farend for this block, so the relevant samples are the last
|
||||
// filter_len entries ending just before the current write position,
|
||||
// offset by how far we are into this near-end frame.
|
||||
//
|
||||
// For sample i of the near-end frame, the corresponding far-end
|
||||
// "now" is far_end_pos - n + i (wrapping).
|
||||
// far_end_pos points to next-write, so most recent sample is at
|
||||
// far_end_pos - 1. For the i-th near-end sample we want the
|
||||
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
||||
// repeatedly to avoid underflow on the usize subtraction.
|
||||
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
echo_est += self.filter_coeffs[k] * fe;
|
||||
power += fe * fe;
|
||||
}
|
||||
|
||||
let error = near_f - echo_est;
|
||||
|
||||
// --- NLMS coefficient update ---
|
||||
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
||||
let step = self.mu * error / norm;
|
||||
|
||||
for k in 0..fl {
|
||||
let fe_idx = (base + fl - k) % fl;
|
||||
let fe = self.far_end_buf[fe_idx];
|
||||
self.filter_coeffs[k] += step * fe;
|
||||
}
|
||||
|
||||
// Clamp output
|
||||
let out = error.max(-32768.0).min(32767.0);
|
||||
nearend[i] = out as i16;
|
||||
|
||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
||||
sum_err_sq += (out as f64) * (out as f64);
|
||||
}
|
||||
|
||||
// ERLE ratio
|
||||
if sum_err_sq < 1.0 {
|
||||
return 100.0; // near-perfect cancellation
|
||||
}
|
||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
||||
}
|
||||
|
||||
/// Enable or disable echo cancellation.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Returns whether echo cancellation is currently enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Reset the adaptive filter to its initial state.
|
||||
///
|
||||
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
||||
pub fn reset(&mut self) {
|
||||
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
||||
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||
self.far_end_pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aec_creates_with_correct_filter_len() {
|
||||
let aec = EchoCanceller::new(48000, 100);
|
||||
assert_eq!(aec.filter_len, 4800);
|
||||
assert_eq!(aec.filter_coeffs.len(), 4800);
|
||||
assert_eq!(aec.far_end_buf.len(), 4800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_passthrough_when_disabled() {
|
||||
let mut aec = EchoCanceller::new(48000, 100);
|
||||
aec.set_enabled(false);
|
||||
assert!(!aec.is_enabled());
|
||||
|
||||
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
|
||||
let mut frame = original.clone();
|
||||
let erle = aec.process_frame(&mut frame);
|
||||
assert_eq!(erle, 1.0);
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_reset_zeroes_state() {
|
||||
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
||||
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
aec.reset();
|
||||
|
||||
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
|
||||
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
|
||||
assert_eq!(aec.far_end_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_reduces_echo_of_known_signal() {
|
||||
// Use a small filter for speed. Feed a known far-end signal, then
|
||||
// present the *same* signal as near-end (perfect echo, no room).
|
||||
// After adaptation the output energy should drop.
|
||||
let filter_ms = 5; // 240 taps at 48 kHz
|
||||
let mut aec = EchoCanceller::new(48000, filter_ms);
|
||||
|
||||
// Generate a simple repeating pattern.
|
||||
let frame_len = 480usize;
|
||||
let make_frame = |offset: usize| -> Vec<i16> {
|
||||
(0..frame_len)
|
||||
.map(|i| {
|
||||
let t = (offset + i) as f64 / 48000.0;
|
||||
(5000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Warm up the adaptive filter with several frames.
|
||||
let mut last_erle = 1.0f32;
|
||||
for frame_idx in 0..40 {
|
||||
let farend = make_frame(frame_idx * frame_len);
|
||||
aec.feed_farend(&farend);
|
||||
|
||||
// Near-end = exact copy of far-end (pure echo).
|
||||
let mut nearend = farend.clone();
|
||||
last_erle = aec.process_frame(&mut nearend);
|
||||
}
|
||||
|
||||
// After 40 frames the ERLE should be meaningfully > 1.
|
||||
assert!(
|
||||
last_erle > 1.0,
|
||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aec_silence_passthrough() {
|
||||
let mut aec = EchoCanceller::new(48000, 10);
|
||||
// Feed silence far-end
|
||||
aec.feed_farend(&vec![0i16; 480]);
|
||||
// Near-end is silence too
|
||||
let mut frame = vec![0i16; 480];
|
||||
let erle = aec.process_frame(&mut frame);
|
||||
assert!(erle >= 1.0);
|
||||
// Output should still be silence
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
}
|
||||
}
|
||||
219
crates/wzp-codec/src/agc.rs
Normal file
219
crates/wzp-codec/src/agc.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Automatic Gain Control (AGC) with two-stage smoothing.
|
||||
//!
|
||||
//! Uses a fast attack / slow release envelope follower to keep the
|
||||
//! output signal near a configurable target RMS level. This prevents
|
||||
//! both clipping (when the speaker is too loud) and inaudibility (when
|
||||
//! the speaker is too quiet or far from the mic).
|
||||
|
||||
/// Two-stage automatic gain control.
|
||||
///
|
||||
/// The gain is adjusted per-frame based on the measured RMS energy,
|
||||
/// with a fast attack (gain decreases quickly when signal gets louder)
|
||||
/// and a slow release (gain increases gradually when signal gets quieter).
|
||||
pub struct AutoGainControl {
|
||||
target_rms: f64,
|
||||
current_gain: f64,
|
||||
min_gain: f64,
|
||||
max_gain: f64,
|
||||
attack_alpha: f64,
|
||||
release_alpha: f64,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl AutoGainControl {
|
||||
/// Create a new AGC with sensible VoIP defaults.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
target_rms: 3000.0, // ~-20 dBFS for i16
|
||||
current_gain: 1.0,
|
||||
min_gain: 0.5,
|
||||
max_gain: 32.0,
|
||||
attack_alpha: 0.3, // fast attack
|
||||
release_alpha: 0.02, // slow release
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a frame of PCM audio in-place, applying gain adjustment.
|
||||
pub fn process_frame(&mut self, pcm: &mut [i16]) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute RMS of the frame.
|
||||
let rms = Self::compute_rms(pcm);
|
||||
|
||||
// Don't amplify near-silence — it would just boost noise.
|
||||
if rms < 10.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Desired instantaneous gain.
|
||||
let desired_gain = (self.target_rms / rms).clamp(self.min_gain, self.max_gain);
|
||||
|
||||
// Smooth the gain transition.
|
||||
let alpha = if desired_gain < self.current_gain {
|
||||
// Signal is louder than target → reduce gain quickly (attack).
|
||||
self.attack_alpha
|
||||
} else {
|
||||
// Signal is quieter than target → raise gain slowly (release).
|
||||
self.release_alpha
|
||||
};
|
||||
|
||||
self.current_gain = self.current_gain * (1.0 - alpha) + desired_gain * alpha;
|
||||
|
||||
// Apply gain to each sample with hard limiting at ±31000 (~0.946 * i16::MAX).
|
||||
const LIMIT: f64 = 31000.0;
|
||||
let gain = self.current_gain;
|
||||
for sample in pcm.iter_mut() {
|
||||
let amplified = (*sample as f64) * gain;
|
||||
let clamped = amplified.clamp(-LIMIT, LIMIT);
|
||||
*sample = clamped as i16;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the AGC.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Returns whether the AGC is currently enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Current gain expressed in dB.
|
||||
pub fn current_gain_db(&self) -> f64 {
|
||||
20.0 * self.current_gain.log10()
|
||||
}
|
||||
|
||||
/// Compute the RMS (root mean square) of a PCM buffer.
|
||||
fn compute_rms(pcm: &[i16]) -> f64 {
|
||||
if pcm.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum_sq: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||
(sum_sq / pcm.len() as f64).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoGainControl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn agc_creates_with_defaults() {
|
||||
let agc = AutoGainControl::new();
|
||||
assert!(agc.is_enabled());
|
||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_passthrough_when_disabled() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
agc.set_enabled(false);
|
||||
|
||||
let original: Vec<i16> = (0..960).map(|i| (i * 5) as i16).collect();
|
||||
let mut frame = original.clone();
|
||||
agc.process_frame(&mut frame);
|
||||
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_does_not_amplify_silence() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
let mut frame = vec![0i16; 960];
|
||||
agc.process_frame(&mut frame);
|
||||
assert!(frame.iter().all(|&s| s == 0));
|
||||
// Gain should remain at initial value.
|
||||
assert!((agc.current_gain - 1.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_amplifies_quiet_signal() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
|
||||
// Very quiet signal (RMS ~ 50).
|
||||
let mut frame: Vec<i16> = (0..960)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48000.0;
|
||||
(50.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Process several frames to let the gain ramp up.
|
||||
for _ in 0..50 {
|
||||
let mut f = frame.clone();
|
||||
agc.process_frame(&mut f);
|
||||
frame = f;
|
||||
}
|
||||
|
||||
// Gain should have increased past 1.0.
|
||||
assert!(
|
||||
agc.current_gain > 1.05,
|
||||
"expected gain > 1.05 for quiet signal, got {}",
|
||||
agc.current_gain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_attenuates_loud_signal() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
|
||||
// Loud signal (RMS ~ 20000).
|
||||
let frame: Vec<i16> = (0..960)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 48000.0;
|
||||
(28000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Process several frames.
|
||||
for _ in 0..20 {
|
||||
let mut f = frame.clone();
|
||||
agc.process_frame(&mut f);
|
||||
}
|
||||
|
||||
// Gain should have decreased below 1.0.
|
||||
assert!(
|
||||
agc.current_gain < 1.0,
|
||||
"expected gain < 1.0 for loud signal, got {}",
|
||||
agc.current_gain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_output_within_limits() {
|
||||
let mut agc = AutoGainControl::new();
|
||||
// Force a high gain by processing many quiet frames first.
|
||||
for _ in 0..100 {
|
||||
let mut f: Vec<i16> = vec![100; 960];
|
||||
agc.process_frame(&mut f);
|
||||
}
|
||||
|
||||
// Now send a louder frame — output should still be within ±31000.
|
||||
let mut frame: Vec<i16> = vec![20000; 960];
|
||||
agc.process_frame(&mut frame);
|
||||
assert!(
|
||||
frame.iter().all(|&s| s.abs() <= 31000),
|
||||
"output samples must be within ±31000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_gain_db_at_unity() {
|
||||
let agc = AutoGainControl::new();
|
||||
let db = agc.current_gain_db();
|
||||
assert!(
|
||||
db.abs() < 0.01,
|
||||
"expected ~0 dB at unity gain, got {db}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! trait-object encoders/decoders that handle adaptive switching internally.
|
||||
|
||||
pub mod adaptive;
|
||||
pub mod aec;
|
||||
pub mod agc;
|
||||
pub mod codec2_dec;
|
||||
pub mod codec2_enc;
|
||||
pub mod denoise;
|
||||
@@ -19,6 +21,8 @@ pub mod resample;
|
||||
pub mod silence;
|
||||
|
||||
pub use adaptive::{AdaptiveDecoder, AdaptiveEncoder};
|
||||
pub use aec::EchoCanceller;
|
||||
pub use agc::AutoGainControl;
|
||||
pub use denoise::NoiseSupressor;
|
||||
pub use silence::{ComfortNoise, SilenceDetector};
|
||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||
|
||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
||||
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||
match profile.codec {
|
||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||
c if c.is_opus() => {
|
||||
self.codec_id = profile.codec;
|
||||
self.frame_duration_ms = profile.frame_duration_ms;
|
||||
Ok(())
|
||||
|
||||
@@ -40,6 +40,11 @@ impl OpusEncoder {
|
||||
.set_signal(Signal::Voice)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||
|
||||
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||
enc.inner
|
||||
.set_complexity(7)
|
||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
@@ -56,6 +61,21 @@ impl OpusEncoder {
|
||||
pub fn frame_samples(&self) -> usize {
|
||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||
}
|
||||
|
||||
/// Set the encoder complexity (0-10). Higher values produce better quality
|
||||
/// at the cost of more CPU. Default is 7.
|
||||
pub fn set_complexity(&mut self, complexity: i32) {
|
||||
let c = (complexity as u8).min(10);
|
||||
let _ = self.inner.set_complexity(c);
|
||||
}
|
||||
|
||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||
///
|
||||
/// Higher values cause the encoder to use more redundancy to survive
|
||||
/// packet loss, at the expense of slightly higher bitrate.
|
||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioEncoder for OpusEncoder {
|
||||
@@ -80,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
|
||||
|
||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||
match profile.codec {
|
||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||
c if c.is_opus() => {
|
||||
self.codec_id = profile.codec;
|
||||
self.frame_duration_ms = profile.frame_duration_ms;
|
||||
self.apply_bitrate(profile.codec)?;
|
||||
|
||||
@@ -1,55 +1,258 @@
|
||||
//! Simple linear resampler for 48 kHz <-> 8 kHz conversion.
|
||||
//! Windowed-sinc FIR resampler for 48 kHz <-> 8 kHz conversion.
|
||||
//!
|
||||
//! These are basic implementations suitable for voice. For higher quality,
|
||||
//! replace with the `rubato` crate later.
|
||||
//! Provides both stateless free functions (backward-compatible) and stateful
|
||||
//! `Downsampler48to8` / `Upsampler8to48` structs that maintain overlap history
|
||||
//! between frames for glitch-free streaming.
|
||||
|
||||
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with averaging).
|
||||
use std::f64::consts::PI;
|
||||
|
||||
// ─── FIR kernel parameters ─────────────────────────────────────────────────
|
||||
|
||||
/// Number of FIR taps in the anti-alias / interpolation filter.
|
||||
const FIR_TAPS: usize = 48;
|
||||
/// Kaiser window beta parameter — controls sidelobe attenuation.
|
||||
const KAISER_BETA: f64 = 8.0;
|
||||
/// Cutoff frequency in Hz for the low-pass filter (just below 4 kHz Nyquist of 8 kHz).
|
||||
const CUTOFF_HZ: f64 = 3800.0;
|
||||
/// Working sample rate in Hz.
|
||||
const SAMPLE_RATE: f64 = 48000.0;
|
||||
/// Decimation / interpolation ratio between 48 kHz and 8 kHz.
|
||||
const RATIO: usize = 6;
|
||||
|
||||
// ─── Kaiser window helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// Zeroth-order modified Bessel function of the first kind, I₀(x).
|
||||
///
|
||||
/// Each output sample is the average of 6 consecutive input samples,
|
||||
/// providing basic anti-aliasing via a box filter.
|
||||
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||
const RATIO: usize = 6;
|
||||
let out_len = input.len() / RATIO;
|
||||
let mut output = Vec::with_capacity(out_len);
|
||||
|
||||
for chunk in input.chunks_exact(RATIO) {
|
||||
let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
|
||||
output.push((sum / RATIO as i32) as i16);
|
||||
/// Computed via the well-known power-series expansion, converging rapidly
|
||||
/// for the moderate values of x used in Kaiser window design.
|
||||
fn bessel_i0(x: f64) -> f64 {
|
||||
let mut sum = 1.0f64;
|
||||
let mut term = 1.0f64;
|
||||
let half_x = x / 2.0;
|
||||
for k in 1..=25 {
|
||||
term *= (half_x / k as f64) * (half_x / k as f64);
|
||||
sum += term;
|
||||
if term < 1e-12 * sum {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
sum
|
||||
}
|
||||
|
||||
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with linear interp).
|
||||
/// Build a windowed-sinc low-pass FIR kernel.
|
||||
///
|
||||
/// Linearly interpolates between each pair of input samples to produce
|
||||
/// 6 output samples per input sample.
|
||||
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||
const RATIO: usize = 6;
|
||||
if input.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
/// Returns `FIR_TAPS` coefficients normalised so that the DC gain is exactly 1.0.
|
||||
fn build_fir_kernel() -> [f64; FIR_TAPS] {
|
||||
let mut kernel = [0.0f64; FIR_TAPS];
|
||||
let m = (FIR_TAPS - 1) as f64;
|
||||
let fc = CUTOFF_HZ / SAMPLE_RATE; // normalised cutoff (0..0.5)
|
||||
let beta_denom = bessel_i0(KAISER_BETA);
|
||||
|
||||
let out_len = input.len() * RATIO;
|
||||
let mut output = Vec::with_capacity(out_len);
|
||||
|
||||
for i in 0..input.len() {
|
||||
let current = input[i] as i32;
|
||||
let next = if i + 1 < input.len() {
|
||||
input[i + 1] as i32
|
||||
for i in 0..FIR_TAPS {
|
||||
// Sinc
|
||||
let n = i as f64 - m / 2.0;
|
||||
let sinc = if n.abs() < 1e-12 {
|
||||
2.0 * fc
|
||||
} else {
|
||||
current // hold last sample
|
||||
(2.0 * PI * fc * n).sin() / (PI * n)
|
||||
};
|
||||
|
||||
for j in 0..RATIO {
|
||||
let interp = current + (next - current) * j as i32 / RATIO as i32;
|
||||
output.push(interp as i16);
|
||||
// Kaiser window
|
||||
let t = 2.0 * i as f64 / m - 1.0; // range [-1, 1]
|
||||
let kaiser = bessel_i0(KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / beta_denom;
|
||||
|
||||
kernel[i] = sinc * kaiser;
|
||||
}
|
||||
|
||||
// Normalise to unity DC gain.
|
||||
let sum: f64 = kernel.iter().sum();
|
||||
if sum.abs() > 1e-15 {
|
||||
for k in kernel.iter_mut() {
|
||||
*k /= sum;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
kernel
|
||||
}
|
||||
|
||||
// ─── Stateful Downsampler 48→8 ─────────────────────────────────────────────
|
||||
|
||||
/// Stateful polyphase FIR downsampler from 48 kHz to 8 kHz.
|
||||
///
|
||||
/// Maintains `FIR_TAPS - 1` samples of history between successive calls to
|
||||
/// `process()` for seamless frame boundaries.
|
||||
pub struct Downsampler48to8 {
|
||||
kernel: [f64; FIR_TAPS],
|
||||
history: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Downsampler48to8 {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
kernel: build_fir_kernel(),
|
||||
history: vec![0.0; FIR_TAPS - 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Downsample a block of 48 kHz samples to 8 kHz.
|
||||
///
|
||||
/// The input length should be a multiple of 6; any trailing samples that
|
||||
/// don't form a complete output sample are consumed into the history.
|
||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
||||
let hist_len = self.history.len(); // FIR_TAPS - 1
|
||||
let total_len = hist_len + input.len();
|
||||
|
||||
// Build a working buffer: history ++ input (as f64).
|
||||
let mut work = Vec::with_capacity(total_len);
|
||||
work.extend_from_slice(&self.history);
|
||||
work.extend(input.iter().map(|&s| s as f64));
|
||||
|
||||
let out_len = input.len() / RATIO;
|
||||
let mut output = Vec::with_capacity(out_len);
|
||||
|
||||
for i in 0..out_len {
|
||||
// The centre of the filter for output sample i sits at
|
||||
// position hist_len + i*RATIO in the work buffer (aligning
|
||||
// with the first new input sample at decimation phase 0).
|
||||
let centre = hist_len + i * RATIO;
|
||||
let start = centre + 1 - FIR_TAPS; // may be 0 for the first few
|
||||
|
||||
let mut acc = 0.0f64;
|
||||
for k in 0..FIR_TAPS {
|
||||
let idx = start + k;
|
||||
if idx < work.len() {
|
||||
acc += work[idx] * self.kernel[k];
|
||||
}
|
||||
}
|
||||
output.push(acc.round().clamp(-32768.0, 32767.0) as i16);
|
||||
}
|
||||
|
||||
// Update history: keep the last (FIR_TAPS - 1) samples from work.
|
||||
if work.len() >= hist_len {
|
||||
self.history
|
||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
} else {
|
||||
// Input was shorter than history — shift.
|
||||
let shift = hist_len - work.len();
|
||||
self.history.copy_within(shift.., 0);
|
||||
for (i, &v) in work.iter().enumerate() {
|
||||
self.history[hist_len - work.len() + i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Downsampler48to8 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stateful Upsampler 8→48 ───────────────────────────────────────────────
|
||||
|
||||
/// Stateful FIR upsampler from 8 kHz to 48 kHz.
|
||||
///
|
||||
/// Inserts zeros between input samples (zero-stuffing), then applies the
|
||||
/// low-pass FIR to remove imaging, with gain compensation of `RATIO`.
|
||||
pub struct Upsampler8to48 {
|
||||
kernel: [f64; FIR_TAPS],
|
||||
history: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Upsampler8to48 {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
kernel: build_fir_kernel(),
|
||||
history: vec![0.0; FIR_TAPS - 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsample a block of 8 kHz samples to 48 kHz.
|
||||
pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
|
||||
let hist_len = self.history.len(); // FIR_TAPS - 1
|
||||
|
||||
// Zero-stuff: insert RATIO-1 zeros between each input sample.
|
||||
let stuffed_len = input.len() * RATIO;
|
||||
let total_len = hist_len + stuffed_len;
|
||||
|
||||
let mut work = Vec::with_capacity(total_len);
|
||||
work.extend_from_slice(&self.history);
|
||||
for &s in input {
|
||||
work.push(s as f64);
|
||||
for _ in 1..RATIO {
|
||||
work.push(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
let out_len = stuffed_len;
|
||||
let mut output = Vec::with_capacity(out_len);
|
||||
|
||||
// The gain factor compensates for the zeros introduced by stuffing.
|
||||
let gain = RATIO as f64;
|
||||
|
||||
for i in 0..out_len {
|
||||
let centre = hist_len + i;
|
||||
let start = centre + 1 - FIR_TAPS;
|
||||
|
||||
let mut acc = 0.0f64;
|
||||
for k in 0..FIR_TAPS {
|
||||
let idx = start + k;
|
||||
if idx < work.len() {
|
||||
acc += work[idx] * self.kernel[k];
|
||||
}
|
||||
}
|
||||
acc *= gain;
|
||||
output.push(acc.round().clamp(-32768.0, 32767.0) as i16);
|
||||
}
|
||||
|
||||
// Update history.
|
||||
if work.len() >= hist_len {
|
||||
self.history
|
||||
.copy_from_slice(&work[work.len() - hist_len..]);
|
||||
} else {
|
||||
let shift = hist_len - work.len();
|
||||
self.history.copy_within(shift.., 0);
|
||||
for (i, &v) in work.iter().enumerate() {
|
||||
self.history[hist_len - work.len() + i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Upsampler8to48 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Backward-compatible free functions ─────────────────────────────────────
|
||||
|
||||
/// Downsample from 48 kHz to 8 kHz (6:1 decimation with FIR anti-alias filter).
|
||||
///
|
||||
/// This is a convenience wrapper that creates a temporary [`Downsampler48to8`].
|
||||
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
||||
/// frames.
|
||||
pub fn resample_48k_to_8k(input: &[i16]) -> Vec<i16> {
|
||||
let mut ds = Downsampler48to8::new();
|
||||
ds.process(input)
|
||||
}
|
||||
|
||||
/// Upsample from 8 kHz to 48 kHz (1:6 interpolation with FIR imaging filter).
|
||||
///
|
||||
/// This is a convenience wrapper that creates a temporary [`Upsampler8to48`].
|
||||
/// For streaming use, prefer the stateful struct to avoid edge artefacts between
|
||||
/// frames.
|
||||
pub fn resample_8k_to_48k(input: &[i16]) -> Vec<i16> {
|
||||
let mut us = Upsampler8to48::new();
|
||||
us.process(input)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -66,12 +269,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dc_signal_preserved() {
|
||||
// A constant signal should survive resampling
|
||||
// A constant signal should survive resampling (approximately).
|
||||
let input = vec![1000i16; 960];
|
||||
let down = resample_48k_to_8k(&input);
|
||||
assert!(down.iter().all(|&s| s == 1000));
|
||||
// Allow some edge transient — check that the middle samples are close.
|
||||
let mid_start = down.len() / 4;
|
||||
let mid_end = 3 * down.len() / 4;
|
||||
for &s in &down[mid_start..mid_end] {
|
||||
assert!(
|
||||
(s - 1000).abs() < 50,
|
||||
"DC downsampled sample {s} too far from 1000"
|
||||
);
|
||||
}
|
||||
|
||||
let up = resample_8k_to_48k(&down);
|
||||
assert!(up.iter().all(|&s| s == 1000));
|
||||
let mid_start_up = up.len() / 4;
|
||||
let mid_end_up = 3 * up.len() / 4;
|
||||
for &s in &up[mid_start_up..mid_end_up] {
|
||||
assert!(
|
||||
(s - 1000).abs() < 100,
|
||||
"DC upsampled sample {s} too far from 1000"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -79,4 +298,40 @@ mod tests {
|
||||
assert!(resample_48k_to_8k(&[]).is_empty());
|
||||
assert!(resample_8k_to_48k(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_downsampler_produces_correct_length() {
|
||||
let mut ds = Downsampler48to8::new();
|
||||
let out = ds.process(&vec![0i16; 960]);
|
||||
assert_eq!(out.len(), 160);
|
||||
let out2 = ds.process(&vec![0i16; 960]);
|
||||
assert_eq!(out2.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_upsampler_produces_correct_length() {
|
||||
let mut us = Upsampler8to48::new();
|
||||
let out = us.process(&vec![0i16; 160]);
|
||||
assert_eq!(out.len(), 960);
|
||||
let out2 = us.process(&vec![0i16; 160]);
|
||||
assert_eq!(out2.len(), 960);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fir_kernel_has_unity_dc_gain() {
|
||||
let kernel = build_fir_kernel();
|
||||
let sum: f64 = kernel.iter().sum();
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 1e-10,
|
||||
"FIR kernel DC gain should be 1.0, got {sum}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bessel_i0_known_values() {
|
||||
// I₀(0) = 1
|
||||
assert!((bessel_i0(0.0) - 1.0).abs() < 1e-12);
|
||||
// I₀(1) ≈ 1.2660658
|
||||
assert!((bessel_i0(1.0) - 1.2660658).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ pub enum CodecId {
|
||||
Codec2_1200 = 4,
|
||||
/// Comfort noise descriptor (silence suppression)
|
||||
ComfortNoise = 5,
|
||||
/// Opus at 32kbps (studio low)
|
||||
Opus32k = 6,
|
||||
/// Opus at 48kbps (studio)
|
||||
Opus48k = 7,
|
||||
/// Opus at 64kbps (studio high)
|
||||
Opus64k = 8,
|
||||
}
|
||||
|
||||
impl CodecId {
|
||||
@@ -27,6 +33,9 @@ impl CodecId {
|
||||
Self::Opus24k => 24_000,
|
||||
Self::Opus16k => 16_000,
|
||||
Self::Opus6k => 6_000,
|
||||
Self::Opus32k => 32_000,
|
||||
Self::Opus48k => 48_000,
|
||||
Self::Opus64k => 64_000,
|
||||
Self::Codec2_3200 => 3_200,
|
||||
Self::Codec2_1200 => 1_200,
|
||||
Self::ComfortNoise => 0,
|
||||
@@ -36,8 +45,7 @@ impl CodecId {
|
||||
/// Preferred frame duration in milliseconds.
|
||||
pub const fn frame_duration_ms(self) -> u8 {
|
||||
match self {
|
||||
Self::Opus24k => 20,
|
||||
Self::Opus16k => 20,
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
||||
Self::Opus6k => 40,
|
||||
Self::Codec2_3200 => 20,
|
||||
Self::Codec2_1200 => 40,
|
||||
@@ -48,7 +56,8 @@ impl CodecId {
|
||||
/// Sample rate expected by this codec.
|
||||
pub const fn sample_rate_hz(self) -> u32 {
|
||||
match self {
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
||||
Self::Opus24k | Self::Opus16k | Self::Opus6k
|
||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
|
||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||
Self::ComfortNoise => 48_000,
|
||||
}
|
||||
@@ -63,6 +72,9 @@ impl CodecId {
|
||||
3 => Some(Self::Codec2_3200),
|
||||
4 => Some(Self::Codec2_1200),
|
||||
5 => Some(Self::ComfortNoise),
|
||||
6 => Some(Self::Opus32k),
|
||||
7 => Some(Self::Opus48k),
|
||||
8 => Some(Self::Opus64k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -71,6 +83,12 @@ impl CodecId {
|
||||
pub const fn to_wire(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// Returns true if this is an Opus variant.
|
||||
pub const fn is_opus(self) -> bool {
|
||||
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
|
||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the complete quality configuration for a call session.
|
||||
@@ -111,6 +129,30 @@ impl QualityProfile {
|
||||
frames_per_block: 8,
|
||||
};
|
||||
|
||||
/// Studio low: Opus 32kbps, minimal FEC.
|
||||
pub const STUDIO_32K: Self = Self {
|
||||
codec: CodecId::Opus32k,
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Studio: Opus 48kbps, minimal FEC.
|
||||
pub const STUDIO_48K: Self = Self {
|
||||
codec: CodecId::Opus48k,
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Studio high: Opus 64kbps, minimal FEC.
|
||||
pub const STUDIO_64K: Self = Self {
|
||||
codec: CodecId::Opus64k,
|
||||
fec_ratio: 0.1,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
};
|
||||
|
||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::packet::MediaPacket;
|
||||
|
||||
@@ -20,19 +21,29 @@ pub struct AdaptivePlayoutDelay {
|
||||
max_delay: usize,
|
||||
/// Exponential moving average of inter-packet arrival jitter (ms).
|
||||
jitter_ema: f64,
|
||||
/// EMA smoothing factor (0.0-1.0, lower = smoother).
|
||||
alpha: f64,
|
||||
/// EMA smoothing factor for jitter increases (fast reaction).
|
||||
alpha_up: f64,
|
||||
/// EMA smoothing factor for jitter decreases (slow decay).
|
||||
alpha_down: f64,
|
||||
/// Last packet arrival timestamp (for computing inter-arrival jitter).
|
||||
last_arrival_ms: Option<u64>,
|
||||
/// Last packet expected timestamp.
|
||||
last_expected_ms: Option<u64>,
|
||||
/// Safety margin added to jitter-derived target (in packets).
|
||||
safety_margin: f64,
|
||||
/// Instant when a jitter spike was detected (handoff detection).
|
||||
spike_detected_at: Option<Instant>,
|
||||
/// Duration to hold max_delay after a spike is detected.
|
||||
spike_cooldown: Duration,
|
||||
/// Multiplier of jitter_ema that constitutes a spike.
|
||||
spike_threshold_multiplier: f64,
|
||||
}
|
||||
|
||||
/// Frame duration in milliseconds (20ms Opus/Codec2 frames).
|
||||
const FRAME_DURATION_MS: f64 = 20.0;
|
||||
/// Safety margin added to jitter-derived target (in packets).
|
||||
const SAFETY_MARGIN_PACKETS: f64 = 2.0;
|
||||
/// Default EMA smoothing factor.
|
||||
/// Default safety margin in packets.
|
||||
const DEFAULT_SAFETY_MARGIN: f64 = 2.0;
|
||||
/// Default EMA smoothing factor (used for both up/down in non-mobile mode).
|
||||
const DEFAULT_ALPHA: f64 = 0.05;
|
||||
|
||||
impl AdaptivePlayoutDelay {
|
||||
@@ -46,9 +57,14 @@ impl AdaptivePlayoutDelay {
|
||||
min_delay,
|
||||
max_delay,
|
||||
jitter_ema: 0.0,
|
||||
alpha: DEFAULT_ALPHA,
|
||||
alpha_up: DEFAULT_ALPHA,
|
||||
alpha_down: DEFAULT_ALPHA,
|
||||
last_arrival_ms: None,
|
||||
last_expected_ms: None,
|
||||
safety_margin: DEFAULT_SAFETY_MARGIN,
|
||||
spike_detected_at: None,
|
||||
spike_cooldown: Duration::from_secs(2),
|
||||
spike_threshold_multiplier: 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,13 +80,38 @@ impl AdaptivePlayoutDelay {
|
||||
let expected_delta = expected_ms as f64 - last_expected as f64;
|
||||
let jitter = (actual_delta - expected_delta).abs();
|
||||
|
||||
// Update EMA
|
||||
self.jitter_ema = self.alpha * jitter + (1.0 - self.alpha) * self.jitter_ema;
|
||||
// Spike detection: check before EMA update
|
||||
if self.jitter_ema > 0.0
|
||||
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
|
||||
{
|
||||
self.spike_detected_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
// Convert jitter estimate to target delay in packets
|
||||
let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + SAFETY_MARGIN_PACKETS;
|
||||
self.target_delay =
|
||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||
// Asymmetric EMA update
|
||||
let alpha = if jitter > self.jitter_ema {
|
||||
self.alpha_up
|
||||
} else {
|
||||
self.alpha_down
|
||||
};
|
||||
self.jitter_ema = alpha * jitter + (1.0 - alpha) * self.jitter_ema;
|
||||
|
||||
// Check if spike cooldown has expired
|
||||
if let Some(spike_time) = self.spike_detected_at {
|
||||
if spike_time.elapsed() >= self.spike_cooldown {
|
||||
self.spike_detected_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
// If within spike cooldown, return max_delay
|
||||
if self.spike_detected_at.is_some() {
|
||||
self.target_delay = self.max_delay;
|
||||
} else {
|
||||
// Convert jitter estimate to target delay in packets
|
||||
let raw_target =
|
||||
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
|
||||
self.target_delay =
|
||||
(raw_target as usize).clamp(self.min_delay, self.max_delay);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_arrival_ms = Some(arrival_ms);
|
||||
@@ -87,6 +128,28 @@ impl AdaptivePlayoutDelay {
|
||||
pub fn jitter_estimate_ms(&self) -> f64 {
|
||||
self.jitter_ema
|
||||
}
|
||||
|
||||
/// Enable or disable mobile mode, adjusting parameters for cellular networks.
|
||||
///
|
||||
/// Mobile mode uses:
|
||||
/// - Asymmetric alpha (fast up=0.3, slow down=0.02) for quicker spike detection
|
||||
/// - Higher safety margin (3.0 packets) to absorb handoff jitter
|
||||
/// - Spike detection with 2-second cooldown at 3x threshold
|
||||
pub fn set_mobile_mode(&mut self, enabled: bool) {
|
||||
if enabled {
|
||||
self.safety_margin = 3.0;
|
||||
self.alpha_up = 0.3;
|
||||
self.alpha_down = 0.02;
|
||||
self.spike_threshold_multiplier = 3.0;
|
||||
self.spike_cooldown = Duration::from_secs(2);
|
||||
} else {
|
||||
self.safety_margin = DEFAULT_SAFETY_MARGIN;
|
||||
self.alpha_up = DEFAULT_ALPHA;
|
||||
self.alpha_down = DEFAULT_ALPHA;
|
||||
self.spike_threshold_multiplier = 3.0;
|
||||
self.spike_cooldown = Duration::from_secs(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -391,6 +454,11 @@ impl JitterBuffer {
|
||||
self.adaptive.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the adaptive playout delay estimator.
|
||||
pub fn adaptive_delay_mut(&mut self) -> Option<&mut AdaptivePlayoutDelay> {
|
||||
self.adaptive.as_mut()
|
||||
}
|
||||
|
||||
/// Adjust target depth based on observed jitter.
|
||||
pub fn set_target_depth(&mut self, depth: usize) {
|
||||
self.target_depth = depth.min(self.max_depth);
|
||||
@@ -720,4 +788,29 @@ mod tests {
|
||||
let ad = jb.adaptive_delay().unwrap();
|
||||
assert_eq!(ad.target_delay(), 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mobile mode tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mobile_mode_increases_safety_margin() {
|
||||
let mut apd = AdaptivePlayoutDelay::new(3, 50);
|
||||
apd.set_mobile_mode(true);
|
||||
assert_eq!(apd.safety_margin, 3.0);
|
||||
assert_eq!(apd.alpha_up, 0.3);
|
||||
assert_eq!(apd.alpha_down, 0.02);
|
||||
|
||||
apd.set_mobile_mode(false);
|
||||
assert_eq!(apd.safety_margin, DEFAULT_SAFETY_MARGIN);
|
||||
assert_eq!(apd.alpha_up, DEFAULT_ALPHA);
|
||||
assert_eq!(apd.alpha_down, DEFAULT_ALPHA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_mode_accessible_via_jitter_buffer() {
|
||||
let mut jb = JitterBuffer::new_adaptive(3, 50);
|
||||
jb.adaptive_delay_mut().unwrap().set_mobile_mode(true);
|
||||
assert_eq!(jb.adaptive_delay().unwrap().safety_margin, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ pub use codec_id::{CodecId, QualityProfile};
|
||||
pub use error::*;
|
||||
pub use packet::{
|
||||
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||
SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||
};
|
||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||
pub use quality::{AdaptiveQualityController, Tier};
|
||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||
pub use session::{Session, SessionEvent, SessionState};
|
||||
pub use traits::*;
|
||||
|
||||
@@ -548,6 +548,9 @@ pub enum SignalMessage {
|
||||
signature: Vec<u8>,
|
||||
/// Supported quality profiles.
|
||||
supported_profiles: Vec<crate::QualityProfile>,
|
||||
/// Optional display name set by the caller.
|
||||
#[serde(default)]
|
||||
alias: Option<String>,
|
||||
},
|
||||
|
||||
/// Call acceptance (analogous to Warzone's WireMessage::CallAnswer).
|
||||
@@ -645,6 +648,42 @@ pub enum SignalMessage {
|
||||
session_id: String,
|
||||
room_name: String,
|
||||
},
|
||||
|
||||
/// Room membership update — sent by relay to all participants when someone joins or leaves.
|
||||
RoomUpdate {
|
||||
/// Current participant count.
|
||||
count: u32,
|
||||
/// List of participants currently in the room.
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
// ── Federation signals (relay-to-relay) ──
|
||||
|
||||
/// Federation: a room exists on the sending relay with active local participants.
|
||||
FederationRoomJoin {
|
||||
room: String,
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
|
||||
/// Federation: a room is now empty on the sending relay.
|
||||
FederationRoomLeave {
|
||||
room: String,
|
||||
},
|
||||
|
||||
/// Federation: local participant list changed for a federated room.
|
||||
FederationParticipantUpdate {
|
||||
room: String,
|
||||
participants: Vec<RoomParticipant>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A participant entry in a RoomUpdate message.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RoomParticipant {
|
||||
/// Identity fingerprint (hex string, stable across reconnects if seed is persisted).
|
||||
pub fingerprint: String,
|
||||
/// Optional display name set by the client.
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
/// Reasons for ending a call.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::packet::QualityReport;
|
||||
use crate::traits::QualityController;
|
||||
@@ -24,24 +25,71 @@ impl Tier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine which tier a quality report belongs to.
|
||||
/// Determine which tier a quality report belongs to (default/WiFi thresholds).
|
||||
pub fn classify(report: &QualityReport) -> Self {
|
||||
Self::classify_with_context(report, NetworkContext::Unknown)
|
||||
}
|
||||
|
||||
/// Classify with network-context-aware thresholds.
|
||||
pub fn classify_with_context(report: &QualityReport, context: NetworkContext) -> Self {
|
||||
let loss = report.loss_percent();
|
||||
let rtt = report.rtt_ms();
|
||||
|
||||
if loss > 40.0 || rtt > 600 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 10.0 || rtt > 400 {
|
||||
Self::Degraded
|
||||
} else {
|
||||
Self::Good
|
||||
match context {
|
||||
NetworkContext::CellularLte
|
||||
| NetworkContext::Cellular5g
|
||||
| NetworkContext::Cellular3g => {
|
||||
// Tighter thresholds for cellular networks
|
||||
if loss > 25.0 || rtt > 500 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 8.0 || rtt > 300 {
|
||||
Self::Degraded
|
||||
} else {
|
||||
Self::Good
|
||||
}
|
||||
}
|
||||
NetworkContext::WiFi | NetworkContext::Unknown => {
|
||||
// Original thresholds
|
||||
if loss > 40.0 || rtt > 600 {
|
||||
Self::Catastrophic
|
||||
} else if loss > 10.0 || rtt > 400 {
|
||||
Self::Degraded
|
||||
} else {
|
||||
Self::Good
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the next lower (worse) tier, or None if already at the worst.
|
||||
pub fn downgrade(self) -> Option<Tier> {
|
||||
match self {
|
||||
Self::Good => Some(Self::Degraded),
|
||||
Self::Degraded => Some(Self::Catastrophic),
|
||||
Self::Catastrophic => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes the network transport type for context-aware quality decisions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkContext {
|
||||
WiFi,
|
||||
CellularLte,
|
||||
Cellular5g,
|
||||
Cellular3g,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for NetworkContext {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||
///
|
||||
/// - Downgrade: 3 consecutive reports in a worse tier
|
||||
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
||||
/// - Upgrade: 10 consecutive reports in a better tier
|
||||
pub struct AdaptiveQualityController {
|
||||
current_tier: Tier,
|
||||
@@ -54,14 +102,26 @@ pub struct AdaptiveQualityController {
|
||||
history: VecDeque<QualityReport>,
|
||||
/// Whether the profile was manually forced (disables adaptive logic).
|
||||
forced: bool,
|
||||
/// Current network context for threshold selection.
|
||||
network_context: NetworkContext,
|
||||
/// FEC boost expiry time (set during network handoff).
|
||||
fec_boost_until: Option<Instant>,
|
||||
/// FEC boost amount to add during handoff recovery window.
|
||||
fec_boost_amount: f32,
|
||||
}
|
||||
|
||||
/// Threshold for downgrading (fast reaction to degradation).
|
||||
const DOWNGRADE_THRESHOLD: u32 = 3;
|
||||
/// Threshold for downgrading on cellular networks (even faster).
|
||||
const CELLULAR_DOWNGRADE_THRESHOLD: u32 = 2;
|
||||
/// Threshold for upgrading (slow, cautious improvement).
|
||||
const UPGRADE_THRESHOLD: u32 = 10;
|
||||
/// Maximum history window size.
|
||||
const HISTORY_SIZE: usize = 20;
|
||||
/// Default FEC boost amount during handoff recovery.
|
||||
const DEFAULT_FEC_BOOST: f32 = 0.2;
|
||||
/// Duration of FEC boost after a network handoff.
|
||||
const FEC_BOOST_DURATION_SECS: u64 = 10;
|
||||
|
||||
impl AdaptiveQualityController {
|
||||
pub fn new() -> Self {
|
||||
@@ -72,6 +132,9 @@ impl AdaptiveQualityController {
|
||||
consecutive_down: 0,
|
||||
history: VecDeque::with_capacity(HISTORY_SIZE),
|
||||
forced: false,
|
||||
network_context: NetworkContext::default(),
|
||||
fec_boost_until: None,
|
||||
fec_boost_amount: DEFAULT_FEC_BOOST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +143,69 @@ impl AdaptiveQualityController {
|
||||
self.current_tier
|
||||
}
|
||||
|
||||
/// Get the current network context.
|
||||
pub fn network_context(&self) -> NetworkContext {
|
||||
self.network_context
|
||||
}
|
||||
|
||||
/// Signal a network transport change (e.g., WiFi to cellular handoff).
|
||||
///
|
||||
/// When switching from WiFi to any cellular type, this preemptively
|
||||
/// downgrades one quality tier and activates a temporary FEC boost.
|
||||
pub fn signal_network_change(&mut self, new_context: NetworkContext) {
|
||||
let old = self.network_context;
|
||||
self.network_context = new_context;
|
||||
|
||||
let new_is_cellular = matches!(
|
||||
new_context,
|
||||
NetworkContext::CellularLte | NetworkContext::Cellular5g | NetworkContext::Cellular3g
|
||||
);
|
||||
|
||||
// If switching from WiFi to cellular, preemptively downgrade one tier
|
||||
if old == NetworkContext::WiFi && new_is_cellular {
|
||||
if let Some(lower_tier) = self.current_tier.downgrade() {
|
||||
self.current_tier = lower_tier;
|
||||
self.current_profile = lower_tier.profile();
|
||||
}
|
||||
// Reset counters to avoid stale hysteresis state
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
// Un-force so adaptive logic resumes
|
||||
self.forced = false;
|
||||
}
|
||||
|
||||
// Activate FEC boost for any network change
|
||||
self.fec_boost_until = Some(Instant::now() + Duration::from_secs(FEC_BOOST_DURATION_SECS));
|
||||
}
|
||||
|
||||
/// Returns the FEC boost amount if within the handoff recovery window, 0.0 otherwise.
|
||||
///
|
||||
/// Callers should add this to their base FEC ratio during the boost window.
|
||||
pub fn fec_boost(&self) -> f32 {
|
||||
if let Some(until) = self.fec_boost_until {
|
||||
if Instant::now() < until {
|
||||
return self.fec_boost_amount;
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
/// Reset the hysteresis counters.
|
||||
pub fn reset_counters(&mut self) {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down = 0;
|
||||
}
|
||||
|
||||
/// Get the effective downgrade threshold based on network context.
|
||||
fn downgrade_threshold(&self) -> u32 {
|
||||
match self.network_context {
|
||||
NetworkContext::CellularLte
|
||||
| NetworkContext::Cellular5g
|
||||
| NetworkContext::Cellular3g => CELLULAR_DOWNGRADE_THRESHOLD,
|
||||
_ => DOWNGRADE_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_transition(&mut self, observed_tier: Tier) -> Option<QualityProfile> {
|
||||
if observed_tier == self.current_tier {
|
||||
self.consecutive_up = 0;
|
||||
@@ -96,7 +222,7 @@ impl AdaptiveQualityController {
|
||||
if is_worse {
|
||||
self.consecutive_up = 0;
|
||||
self.consecutive_down += 1;
|
||||
if self.consecutive_down >= DOWNGRADE_THRESHOLD {
|
||||
if self.consecutive_down >= self.downgrade_threshold() {
|
||||
self.current_tier = observed_tier;
|
||||
self.current_profile = observed_tier.profile();
|
||||
self.consecutive_down = 0;
|
||||
@@ -142,7 +268,7 @@ impl QualityController for AdaptiveQualityController {
|
||||
return None;
|
||||
}
|
||||
|
||||
let observed = Tier::classify(report);
|
||||
let observed = Tier::classify_with_context(report, self.network_context);
|
||||
self.try_transition(observed)
|
||||
}
|
||||
|
||||
@@ -246,4 +372,110 @@ mod tests {
|
||||
assert_eq!(Tier::classify(&make_report(50.0, 200)), Tier::Catastrophic);
|
||||
assert_eq!(Tier::classify(&make_report(5.0, 700)), Tier::Catastrophic);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Network context tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cellular_tighter_thresholds() {
|
||||
// 12% loss: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(12.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
|
||||
// 9% loss: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(9.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
|
||||
// 30% loss: Degraded on WiFi, Catastrophic on cellular
|
||||
let report = make_report(30.0, 200);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Degraded
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::Cellular3g),
|
||||
Tier::Catastrophic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_rtt_thresholds() {
|
||||
// RTT 350ms: Good on WiFi, Degraded on cellular
|
||||
let report = make_report(2.0, 348); // rtt_4ms rounds so use 348
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::WiFi),
|
||||
Tier::Good
|
||||
);
|
||||
assert_eq!(
|
||||
Tier::classify_with_context(&report, NetworkContext::CellularLte),
|
||||
Tier::Degraded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_faster_downgrade() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
// Reset tier back to Good for testing downgrade threshold
|
||||
ctrl.current_tier = Tier::Good;
|
||||
ctrl.current_profile = Tier::Good.profile();
|
||||
|
||||
// On cellular, downgrade threshold is 2 instead of 3
|
||||
let bad = make_report(50.0, 200);
|
||||
assert!(ctrl.observe(&bad).is_none()); // 1st bad
|
||||
let result = ctrl.observe(&bad); // 2nd bad — should trigger on cellular
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_network_change_preemptive_downgrade() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
assert_eq!(ctrl.tier(), Tier::Good);
|
||||
|
||||
// Switch from WiFi to cellular
|
||||
ctrl.network_context = NetworkContext::WiFi;
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
|
||||
// Should have downgraded one tier: Good -> Degraded
|
||||
assert_eq!(ctrl.tier(), Tier::Degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_network_change_fec_boost() {
|
||||
let mut ctrl = AdaptiveQualityController::new();
|
||||
assert_eq!(ctrl.fec_boost(), 0.0);
|
||||
|
||||
ctrl.signal_network_change(NetworkContext::CellularLte);
|
||||
|
||||
// FEC boost should be active
|
||||
assert!(ctrl.fec_boost() > 0.0);
|
||||
assert_eq!(ctrl.fec_boost(), DEFAULT_FEC_BOOST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_downgrade() {
|
||||
assert_eq!(Tier::Good.downgrade(), Some(Tier::Degraded));
|
||||
assert_eq!(Tier::Degraded.downgrade(), Some(Tier::Catastrophic));
|
||||
assert_eq!(Tier::Catastrophic.downgrade(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_context_default() {
|
||||
assert_eq!(NetworkContext::default(), NetworkContext::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ prometheus = "0.13"
|
||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "6"
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "wzp-relay"
|
||||
|
||||
@@ -3,8 +3,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Configuration for the relay daemon.
|
||||
/// A federated peer relay.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
/// Address of the peer relay (e.g., "193.180.213.68:4433").
|
||||
pub url: String,
|
||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
||||
pub fingerprint: String,
|
||||
/// Optional human-readable label.
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for the relay daemon.
|
||||
///
|
||||
/// All fields have defaults, so a minimal TOML file only needs the
|
||||
/// fields you want to override (e.g., just `[[peers]]`).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RelayConfig {
|
||||
/// Address to listen on for incoming connections (client-facing).
|
||||
pub listen_addr: SocketAddr,
|
||||
@@ -44,6 +60,9 @@ pub struct RelayConfig {
|
||||
pub ws_port: Option<u16>,
|
||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||
pub static_dir: Option<String>,
|
||||
/// Federation peer relays.
|
||||
#[serde(default)]
|
||||
pub peers: Vec<PeerConfig>,
|
||||
}
|
||||
|
||||
impl Default for RelayConfig {
|
||||
@@ -62,6 +81,14 @@ impl Default for RelayConfig {
|
||||
trunking_enabled: false,
|
||||
ws_port: None,
|
||||
static_dir: None,
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load relay configuration from a TOML file.
|
||||
pub fn load_config(path: &str) -> Result<RelayConfig, anyhow::Error> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: RelayConfig = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
284
crates/wzp-relay/src/federation.rs
Normal file
284
crates/wzp-relay/src/federation.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! Relay federation — connects to peer relays and bridges rooms with matching names.
|
||||
//!
|
||||
//! Each federated peer is represented as a virtual participant in shared rooms.
|
||||
//! Media from local participants is forwarded to the peer via room-tagged datagrams.
|
||||
//! Media from the peer is received, demuxed by room hash, and forwarded to local participants.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use sha2::{Sha256, Digest};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::{MediaTransport, SignalMessage};
|
||||
use wzp_transport::QuinnTransport;
|
||||
|
||||
use crate::config::PeerConfig;
|
||||
use crate::room::{self, ParticipantSender, RoomManager};
|
||||
|
||||
/// Compute 8-byte room hash for federation datagram tagging.
|
||||
pub fn room_hash(room_name: &str) -> [u8; 8] {
|
||||
let h = Sha256::digest(room_name.as_bytes());
|
||||
let mut out = [0u8; 8];
|
||||
out.copy_from_slice(&h[..8]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Manages federation connections to peer relays.
|
||||
pub struct FederationManager {
|
||||
peers: Vec<PeerConfig>,
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
endpoint: quinn::Endpoint,
|
||||
local_tls_fp: String,
|
||||
}
|
||||
|
||||
impl FederationManager {
|
||||
pub fn new(
|
||||
peers: Vec<PeerConfig>,
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
endpoint: quinn::Endpoint,
|
||||
local_tls_fp: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
peers,
|
||||
room_mgr,
|
||||
endpoint,
|
||||
local_tls_fp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start federation — spawns one task per configured peer.
|
||||
pub async fn run(self: Arc<Self>) {
|
||||
if self.peers.is_empty() {
|
||||
return;
|
||||
}
|
||||
info!(peers = self.peers.len(), "federation starting");
|
||||
let mut handles = Vec::new();
|
||||
for peer in &self.peers {
|
||||
let this = self.clone();
|
||||
let peer = peer.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
run_peer_loop(this, peer).await;
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an inbound federation connection from a peer that we recognize.
|
||||
pub async fn handle_inbound(
|
||||
self: &Arc<Self>,
|
||||
transport: Arc<QuinnTransport>,
|
||||
peer_config: PeerConfig,
|
||||
) {
|
||||
let addr: SocketAddr = peer_config.url.parse().unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap());
|
||||
info!(peer = ?peer_config.label, %addr, "inbound federation link active");
|
||||
if let Err(e) = run_federation_link(self.clone(), transport, addr, &peer_config).await {
|
||||
warn!(peer = ?peer_config.label, "inbound federation link ended: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a configured peer by TLS fingerprint.
|
||||
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
|
||||
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a fingerprint string (remove colons, lowercase).
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.replace(':', "").to_lowercase()
|
||||
}
|
||||
|
||||
/// Persistent connection loop for one peer — reconnects with backoff.
|
||||
async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
|
||||
let mut backoff = Duration::from_secs(5);
|
||||
loop {
|
||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connecting to peer...");
|
||||
match connect_to_peer(&fm, &peer).await {
|
||||
Ok(transport) => {
|
||||
backoff = Duration::from_secs(5); // reset on success
|
||||
let addr: SocketAddr = peer.url.parse().unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap());
|
||||
if let Err(e) = run_federation_link(fm.clone(), transport, addr, &peer).await {
|
||||
warn!(peer_url = %peer.url, "federation link ended: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(peer_url = %peer.url, backoff_s = backoff.as_secs(), "federation connect failed: {e}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a peer relay.
|
||||
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
|
||||
let addr: SocketAddr = peer.url.parse()?;
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
|
||||
// TODO: verify peer TLS fingerprint once we have cert access
|
||||
let transport = Arc::new(QuinnTransport::new(conn));
|
||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected to peer");
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
/// Run the federation link: exchange room info and forward media.
|
||||
async fn run_federation_link(
|
||||
fm: Arc<FederationManager>,
|
||||
transport: Arc<QuinnTransport>,
|
||||
peer_addr: SocketAddr,
|
||||
peer: &PeerConfig,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
// Announce our active rooms to the peer
|
||||
let rooms = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
mgr.active_rooms()
|
||||
};
|
||||
for room_name in &rooms {
|
||||
let participants = {
|
||||
let mgr = fm.room_mgr.lock().await;
|
||||
mgr.local_participants(room_name)
|
||||
};
|
||||
let msg = SignalMessage::FederationRoomJoin {
|
||||
room: room_name.clone(),
|
||||
participants,
|
||||
};
|
||||
transport.send_signal(&msg).await?;
|
||||
}
|
||||
|
||||
// Track virtual participants we create on behalf of this peer
|
||||
let mut peer_room_participants: HashMap<String, room::ParticipantId> = HashMap::new();
|
||||
// Map room_hash -> room_name for incoming media demux
|
||||
let mut hash_to_room: HashMap<[u8; 8], String> = HashMap::new();
|
||||
|
||||
// Run two tasks: recv signals + recv media datagrams
|
||||
let signal_transport = transport.clone();
|
||||
let media_transport = transport.clone();
|
||||
let fm_signal = fm.clone();
|
||||
let fm_media = fm.clone();
|
||||
let peer_label = peer.label.clone().unwrap_or_else(|| peer.url.clone());
|
||||
|
||||
let signal_task = async move {
|
||||
loop {
|
||||
match signal_transport.recv_signal().await {
|
||||
Ok(Some(msg)) => {
|
||||
match msg {
|
||||
SignalMessage::FederationRoomJoin { room, participants } => {
|
||||
info!(peer = %peer_label, room = %room, count = participants.len(), "federation: peer room join");
|
||||
let rh = room_hash(&room);
|
||||
hash_to_room.insert(rh, room.clone());
|
||||
|
||||
let sender = ParticipantSender::Federation {
|
||||
transport: signal_transport.clone(),
|
||||
room_hash: rh,
|
||||
};
|
||||
let (pid, update, senders) = {
|
||||
let mut mgr = fm_signal.room_mgr.lock().await;
|
||||
mgr.join_federated(&room, peer_addr, sender, participants)
|
||||
};
|
||||
peer_room_participants.insert(room, pid);
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
SignalMessage::FederationRoomLeave { room } => {
|
||||
info!(peer = %peer_label, room = %room, "federation: peer room leave");
|
||||
if let Some(pid) = peer_room_participants.remove(&room) {
|
||||
let result = {
|
||||
let mut mgr = fm_signal.room_mgr.lock().await;
|
||||
mgr.leave(&room, pid)
|
||||
};
|
||||
if let Some((update, senders)) = result {
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
}
|
||||
hash_to_room.retain(|_, v| v != &room);
|
||||
}
|
||||
SignalMessage::FederationParticipantUpdate { room, participants } => {
|
||||
let result = {
|
||||
let mut mgr = fm_signal.room_mgr.lock().await;
|
||||
mgr.update_federated_participants(&room, peer_addr, participants)
|
||||
};
|
||||
if let Some((update, senders)) = result {
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
}
|
||||
_ => {} // ignore other signals
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
error!(peer = %peer_label, "federation signal recv error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cleanup: remove all virtual participants for this peer
|
||||
for (room, pid) in &peer_room_participants {
|
||||
let result = {
|
||||
let mut mgr = fm_signal.room_mgr.lock().await;
|
||||
mgr.leave(room, *pid)
|
||||
};
|
||||
if let Some((update, senders)) = result {
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
}
|
||||
info!(peer = %peer_label, "federation signal task ended");
|
||||
};
|
||||
|
||||
let media_task = async move {
|
||||
loop {
|
||||
match media_transport.connection().read_datagram().await {
|
||||
Ok(data) => {
|
||||
if data.len() < 8 + 4 {
|
||||
continue; // too short (need room_hash + min header)
|
||||
}
|
||||
let mut rh = [0u8; 8];
|
||||
rh.copy_from_slice(&data[..8]);
|
||||
let media_bytes = &data[8..];
|
||||
|
||||
// Deserialize media packet
|
||||
let pkt = match wzp_proto::MediaPacket::from_bytes(Bytes::copy_from_slice(media_bytes)) {
|
||||
Some(pkt) => pkt,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Look up room by hash — we need to get the room name from the signal task's hash_to_room
|
||||
// For simplicity, we forward to all local participants via the room manager
|
||||
// The virtual participant approach means we don't need the room name here —
|
||||
// the SFU loop handles it. But since inbound media doesn't go through run_participant,
|
||||
// we need to manually fan out.
|
||||
|
||||
// For now, just use the room manager to find local participants
|
||||
// This is a simplified approach — full implementation would maintain
|
||||
// a shared hash_to_room map between signal and media tasks
|
||||
let mgr = fm_media.room_mgr.lock().await;
|
||||
for room_name in mgr.active_rooms() {
|
||||
if room_hash(&room_name) == rh {
|
||||
// Forward to all local participants in this room
|
||||
let locals: Vec<_> = mgr.local_senders(&room_name);
|
||||
drop(mgr); // release lock before sending
|
||||
for sender in &locals {
|
||||
if let ParticipantSender::Quic(t) = sender {
|
||||
let _ = t.send_media(&pkt).await;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = signal_task => {}
|
||||
_ = media_task => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,25 +15,27 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
|
||||
/// 5. Derive shared ChaCha20-Poly1305 session
|
||||
/// 6. Send `CallAnswer` back
|
||||
///
|
||||
/// Returns the derived `CryptoSession` and the chosen `QualityProfile`.
|
||||
/// Returns the derived `CryptoSession`, the chosen `QualityProfile`, the caller's fingerprint,
|
||||
/// and the caller's alias (if provided in CallOffer).
|
||||
pub async fn accept_handshake(
|
||||
transport: &dyn MediaTransport,
|
||||
seed: &[u8; 32],
|
||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile), anyhow::Error> {
|
||||
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> {
|
||||
// 1. Receive CallOffer
|
||||
let offer = transport
|
||||
.recv_signal()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
|
||||
|
||||
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles) =
|
||||
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) =
|
||||
match offer {
|
||||
SignalMessage::CallOffer {
|
||||
identity_pub,
|
||||
ephemeral_pub,
|
||||
signature,
|
||||
supported_profiles,
|
||||
} => (identity_pub, ephemeral_pub, signature, supported_profiles),
|
||||
alias,
|
||||
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias),
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"expected CallOffer, got {:?}",
|
||||
@@ -76,7 +78,13 @@ pub async fn accept_handshake(
|
||||
};
|
||||
transport.send_signal(&answer).await?;
|
||||
|
||||
Ok((session, chosen_profile))
|
||||
// Derive caller fingerprint from their identity public key (first 8 bytes as hex)
|
||||
let caller_fp = caller_identity_pub[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
|
||||
Ok((session, chosen_profile, caller_fp, caller_alias))
|
||||
}
|
||||
|
||||
/// Select the best quality profile from those the caller supports.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod federation;
|
||||
pub mod handshake;
|
||||
pub mod metrics;
|
||||
pub mod pipeline;
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::MediaTransport;
|
||||
use wzp_relay::config::RelayConfig;
|
||||
@@ -24,11 +24,34 @@ use wzp_relay::room::{self, RoomManager};
|
||||
use wzp_relay::session_mgr::SessionManager;
|
||||
|
||||
fn parse_args() -> RelayConfig {
|
||||
let mut config = RelayConfig::default();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Check for --config first to use as base
|
||||
let mut config_file = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
if args[i] == "--config" {
|
||||
i += 1;
|
||||
config_file = args.get(i).cloned();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let mut config = if let Some(ref path) = config_file {
|
||||
wzp_relay::config::load_config(path)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("failed to load config from {path}: {e}");
|
||||
std::process::exit(1);
|
||||
})
|
||||
} else {
|
||||
RelayConfig::default()
|
||||
};
|
||||
|
||||
// CLI flags override config file values
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--config" => { i += 1; } // already handled
|
||||
"--listen" => {
|
||||
i += 1;
|
||||
config.listen_addr = args.get(i).expect("--listen requires an address")
|
||||
@@ -90,9 +113,10 @@ fn parse_args() -> RelayConfig {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||
eprintln!("Usage: wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||
eprintln!();
|
||||
eprintln!("Options:");
|
||||
eprintln!(" --config <path> Load configuration from TOML file (peers, listen, etc.)");
|
||||
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
||||
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
||||
@@ -184,6 +208,21 @@ async fn run_downstream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a non-loopback IP address from local interfaces.
|
||||
/// Prefers public IPs over private (10.x, 172.16-31.x, 192.168.x).
|
||||
fn detect_public_ip() -> Option<String> {
|
||||
use std::net::UdpSocket;
|
||||
// Connect to a public address to find our outbound IP (doesn't actually send anything)
|
||||
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(addr) = socket.local_addr() {
|
||||
return Some(addr.ip().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let config = parse_args();
|
||||
@@ -207,12 +246,63 @@ async fn main() -> anyhow::Result<()> {
|
||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||
}
|
||||
|
||||
// Generate ephemeral relay identity for crypto handshake
|
||||
let relay_seed = wzp_crypto::Seed::generate();
|
||||
// Load or generate relay identity — persisted in ~/.wzp/relay-identity
|
||||
let relay_seed = {
|
||||
let config_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".wzp");
|
||||
let identity_path = config_dir.join("relay-identity");
|
||||
if identity_path.exists() {
|
||||
if let Ok(hex) = std::fs::read_to_string(&identity_path) {
|
||||
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||
info!("loaded relay identity from {}", identity_path.display());
|
||||
s
|
||||
} else {
|
||||
warn!("corrupt relay identity file, generating new");
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&identity_path, &hex);
|
||||
s
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&identity_path, &hex);
|
||||
s
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let _ = std::fs::create_dir_all(&config_dir);
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&identity_path, &hex);
|
||||
info!("generated relay identity at {}", identity_path.display());
|
||||
s
|
||||
}
|
||||
};
|
||||
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
||||
|
||||
let (server_config, _cert) = wzp_transport::server_config();
|
||||
let (server_config, cert_der) = wzp_transport::server_config_from_seed(&relay_seed.0);
|
||||
let tls_fp = wzp_transport::tls_fingerprint(&cert_der);
|
||||
info!(tls_fingerprint = %tls_fp, "TLS certificate (deterministic from relay identity)");
|
||||
|
||||
// Print federation hint with our public IP + listen port + TLS fingerprint
|
||||
let listen_port = config.listen_addr.port();
|
||||
let public_ip = detect_public_ip();
|
||||
if let Some(ip) = &public_ip {
|
||||
info!("federation: to peer with this relay, add to relay.toml:");
|
||||
info!(" [[peers]]");
|
||||
info!(" url = \"{ip}:{listen_port}\"");
|
||||
info!(" fingerprint = \"{tls_fp}\"");
|
||||
}
|
||||
|
||||
// Log configured peers
|
||||
if !config.peers.is_empty() {
|
||||
info!(count = config.peers.len(), "federation peers configured");
|
||||
for p in &config.peers {
|
||||
info!(url = %p.url, label = ?p.label, " peer");
|
||||
}
|
||||
}
|
||||
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
||||
|
||||
// Forward mode
|
||||
@@ -230,6 +320,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Room manager (room mode only)
|
||||
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
||||
|
||||
// Federation manager
|
||||
let federation_mgr = if !config.peers.is_empty() {
|
||||
let fm = Arc::new(wzp_relay::federation::FederationManager::new(
|
||||
config.peers.clone(),
|
||||
room_mgr.clone(),
|
||||
endpoint.clone(),
|
||||
tls_fp.clone(),
|
||||
));
|
||||
let fm_run = fm.clone();
|
||||
tokio::spawn(async move { fm_run.run().await });
|
||||
Some(fm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Session manager — enforces max concurrent sessions
|
||||
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
||||
|
||||
@@ -285,6 +390,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let trunking_enabled = config.trunking_enabled;
|
||||
let presence = presence.clone();
|
||||
let route_resolver = route_resolver.clone();
|
||||
let federation_mgr = federation_mgr.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let addr = connection.remote_address();
|
||||
@@ -299,6 +405,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||
|
||||
// Ping connections: client just measures QUIC connect RTT.
|
||||
// No handshake, no streams — client closes immediately after connecting.
|
||||
if room_name == "ping" {
|
||||
info!(%addr, "ping connection (RTT probe)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe connections use SNI "_probe" to identify themselves.
|
||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||
if room_name == "_probe" {
|
||||
@@ -385,6 +498,38 @@ async fn main() -> anyhow::Result<()> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Federation connections use SNI "_federation"
|
||||
if room_name == "_federation" {
|
||||
if let Some(ref fm) = federation_mgr {
|
||||
// Check if we recognize this peer by TLS fingerprint
|
||||
let peer_fp = wzp_transport::tls_fingerprint(
|
||||
&transport.connection()
|
||||
.peer_identity()
|
||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||
.and_then(|certs| certs.first().cloned())
|
||||
.map(|c| c.to_vec())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
if let Some(peer_config) = fm.find_peer_by_fingerprint(&peer_fp) {
|
||||
let peer_config = peer_config.clone();
|
||||
let fm = fm.clone();
|
||||
info!(%addr, label = ?peer_config.label, "inbound federation connection accepted");
|
||||
fm.handle_inbound(transport, peer_config).await;
|
||||
} else {
|
||||
warn!(%addr, "unknown relay wants to federate");
|
||||
info!(" to accept, add to relay.toml:");
|
||||
info!(" [[peers]]");
|
||||
info!(" url = \"{addr}\"");
|
||||
info!(" fingerprint = \"{peer_fp}\"");
|
||||
transport.close().await.ok();
|
||||
}
|
||||
} else {
|
||||
info!(%addr, "federation connection rejected (no peers configured)");
|
||||
transport.close().await.ok();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth check: if --auth-url is set, expect first signal message to be a token
|
||||
// Auth: if --auth-url is set, expect AuthToken as first signal
|
||||
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||
@@ -431,7 +576,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Crypto handshake: verify client identity + negotiate quality profile
|
||||
let handshake_start = std::time::Instant::now();
|
||||
let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake(
|
||||
let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake(
|
||||
&*transport,
|
||||
&relay_seed_bytes,
|
||||
).await {
|
||||
@@ -448,10 +593,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// Use the caller's identity fingerprint from the handshake
|
||||
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
|
||||
|
||||
// Register in presence registry
|
||||
if let Some(ref fp) = authenticated_fp {
|
||||
{
|
||||
let mut reg = presence.lock().await;
|
||||
reg.register_local(fp, None, Some(room_name.clone()));
|
||||
reg.register_local(&participant_fp, None, Some(room_name.clone()));
|
||||
}
|
||||
|
||||
info!(%addr, room = %room_name, "client joining");
|
||||
@@ -502,14 +650,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let participant_id = {
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
match mgr.join(&room_name, addr, room::ParticipantSender::Quic(transport.clone()), authenticated_fp.as_deref()) {
|
||||
Ok(id) => {
|
||||
match mgr.join(
|
||||
&room_name,
|
||||
addr,
|
||||
room::ParticipantSender::Quic(transport.clone()),
|
||||
Some(&participant_fp),
|
||||
caller_alias.as_deref(),
|
||||
) {
|
||||
Ok((id, update, senders)) => {
|
||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||
drop(mgr); // release lock before async broadcast
|
||||
room::broadcast_signal(&senders, &update).await;
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%addr, room = %room_name, "room join denied: {e}");
|
||||
// Clean up the session we just created
|
||||
metrics.active_sessions.dec();
|
||||
let mut smgr = session_mgr.lock().await;
|
||||
smgr.remove_session(session_id);
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
use wzp_proto::packet::TrunkFrame;
|
||||
use wzp_proto::MediaTransport;
|
||||
@@ -27,11 +27,25 @@ fn next_id() -> ParticipantId {
|
||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Tracks where a participant originates from (for loop prevention).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ParticipantOrigin {
|
||||
/// Connected directly to this relay.
|
||||
Local,
|
||||
/// Virtual participant representing a federated peer relay.
|
||||
Federated { relay_addr: std::net::SocketAddr },
|
||||
}
|
||||
|
||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||
#[derive(Clone)]
|
||||
pub enum ParticipantSender {
|
||||
Quic(Arc<wzp_transport::QuinnTransport>),
|
||||
WebSocket(tokio::sync::mpsc::Sender<Bytes>),
|
||||
/// Federated peer relay — media is prefixed with an 8-byte room hash.
|
||||
Federation {
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
room_hash: [u8; 8],
|
||||
},
|
||||
}
|
||||
|
||||
impl ParticipantSender {
|
||||
@@ -50,6 +64,14 @@ impl ParticipantSender {
|
||||
};
|
||||
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}"))
|
||||
}
|
||||
ParticipantSender::Federation { transport, room_hash } => {
|
||||
// Prefix media data with room hash for demuxing on the peer relay
|
||||
let mut tagged = Vec::with_capacity(8 + data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(data);
|
||||
transport.send_raw_datagram(&tagged)
|
||||
.map_err(|e| format!("federation send: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,29 +89,53 @@ impl ParticipantSender {
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast a signal message to a list of participant senders.
|
||||
pub async fn broadcast_signal(senders: &[ParticipantSender], msg: &wzp_proto::SignalMessage) {
|
||||
for sender in senders {
|
||||
if let ParticipantSender::Quic(t) = sender {
|
||||
if let Err(e) = t.send_signal(msg).await {
|
||||
warn!("broadcast_signal error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A participant in a room.
|
||||
struct Participant {
|
||||
id: ParticipantId,
|
||||
_addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<String>,
|
||||
alias: Option<String>,
|
||||
origin: ParticipantOrigin,
|
||||
}
|
||||
|
||||
/// A room holding multiple participants.
|
||||
struct Room {
|
||||
participants: Vec<Participant>,
|
||||
/// Remote participants from federated peers (for merged RoomUpdate).
|
||||
federated_participants: HashMap<std::net::SocketAddr, Vec<wzp_proto::packet::RoomParticipant>>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
participants: Vec::new(),
|
||||
federated_participants: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, addr: std::net::SocketAddr, sender: ParticipantSender) -> ParticipantId {
|
||||
fn add(
|
||||
&mut self,
|
||||
addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<String>,
|
||||
alias: Option<String>,
|
||||
origin: ParticipantOrigin,
|
||||
) -> ParticipantId {
|
||||
let id = next_id();
|
||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||
self.participants.push(Participant { id, _addr: addr, sender });
|
||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, ?origin, "joined room");
|
||||
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias, origin });
|
||||
id
|
||||
}
|
||||
|
||||
@@ -106,6 +152,45 @@ impl Room {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get senders with loop prevention for federation.
|
||||
///
|
||||
/// - Media from a **local** participant → send to ALL others (local + federated)
|
||||
/// - Media from a **federated** participant → send to LOCAL participants only
|
||||
/// (the source relay already forwarded to its own locals and other peers)
|
||||
fn others_for_origin(&self, exclude_id: ParticipantId, source_origin: &ParticipantOrigin) -> Vec<ParticipantSender> {
|
||||
self.participants
|
||||
.iter()
|
||||
.filter(|p| p.id != exclude_id)
|
||||
.filter(|p| match source_origin {
|
||||
ParticipantOrigin::Local => true,
|
||||
ParticipantOrigin::Federated { .. } => p.origin == ParticipantOrigin::Local,
|
||||
})
|
||||
.map(|p| p.sender.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a RoomUpdate participant list (local + federated).
|
||||
fn participant_list(&self) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
let mut list: Vec<_> = self.participants
|
||||
.iter()
|
||||
.filter(|p| p.origin == ParticipantOrigin::Local)
|
||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||
alias: p.alias.clone(),
|
||||
})
|
||||
.collect();
|
||||
// Merge federated participants from all peer relays
|
||||
for remote in self.federated_participants.values() {
|
||||
list.extend(remote.iter().cloned());
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Get all senders (for broadcasting to everyone including the joiner).
|
||||
fn all_senders(&self) -> Vec<ParticipantSender> {
|
||||
self.participants.iter().map(|p| p.sender.clone()).collect()
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.participants.is_empty()
|
||||
}
|
||||
@@ -165,20 +250,27 @@ impl RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a room. Returns the participant ID or an error if unauthorized.
|
||||
/// Join a room. Returns (participant_id, room_update_msg, all_senders) for broadcasting.
|
||||
pub fn join(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<ParticipantId, String> {
|
||||
alias: Option<&str>,
|
||||
) -> Result<(ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>), String> {
|
||||
if !self.is_authorized(room_name, fingerprint) {
|
||||
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
||||
return Err("not authorized for this room".to_string());
|
||||
}
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
Ok(room.add(addr, sender))
|
||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()), ParticipantOrigin::Local);
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
Ok((id, update, senders))
|
||||
}
|
||||
|
||||
/// Join a room via WebSocket. Convenience wrapper around `join()`.
|
||||
@@ -189,17 +281,104 @@ impl RoomManager {
|
||||
sender: tokio::sync::mpsc::Sender<Bytes>,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<ParticipantId, String> {
|
||||
self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint)
|
||||
let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Leave a room. Removes the room if empty.
|
||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) {
|
||||
/// Join a room as a federated virtual participant.
|
||||
pub fn join_federated(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
relay_addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
remote_participants: Vec<wzp_proto::packet::RoomParticipant>,
|
||||
) -> (ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>) {
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
room.federated_participants.insert(relay_addr, remote_participants);
|
||||
let id = room.add(
|
||||
relay_addr, sender, None, Some("(federated)".to_string()),
|
||||
ParticipantOrigin::Federated { relay_addr },
|
||||
);
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
(id, update, senders)
|
||||
}
|
||||
|
||||
/// Update federated participant list for a room (from FederationParticipantUpdate).
|
||||
pub fn update_federated_participants(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
relay_addr: std::net::SocketAddr,
|
||||
participants: Vec<wzp_proto::packet::RoomParticipant>,
|
||||
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
room.federated_participants.insert(relay_addr, participants);
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
Some((update, senders))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the origin of a participant by ID.
|
||||
pub fn participant_origin(&self, room_name: &str, participant_id: ParticipantId) -> Option<ParticipantOrigin> {
|
||||
self.rooms.get(room_name)
|
||||
.and_then(|room| room.participants.iter().find(|p| p.id == participant_id))
|
||||
.map(|p| p.origin.clone())
|
||||
}
|
||||
|
||||
/// Get list of active room names (for federation room announcements).
|
||||
pub fn active_rooms(&self) -> Vec<String> {
|
||||
self.rooms.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get local participant list for a room (excludes federated virtual participants).
|
||||
pub fn local_participants(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
self.rooms.get(room_name)
|
||||
.map(|room| room.participants.iter()
|
||||
.filter(|p| p.origin == ParticipantOrigin::Local)
|
||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||
alias: p.alias.clone(),
|
||||
})
|
||||
.collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get senders for local-only participants in a room (for federation inbound media).
|
||||
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
|
||||
self.rooms.get(room_name)
|
||||
.map(|room| room.participants.iter()
|
||||
.filter(|p| p.origin == ParticipantOrigin::Local)
|
||||
.map(|p| p.sender.clone())
|
||||
.collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
room.remove(participant_id);
|
||||
if room.is_empty() {
|
||||
self.rooms.remove(room_name);
|
||||
info!(room = room_name, "room closed (empty)");
|
||||
return None;
|
||||
}
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
Some((update, senders))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,55 +502,134 @@ async fn run_participant_plain(
|
||||
) {
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
let mut last_recv_instant = std::time::Instant::now();
|
||||
let mut max_recv_gap_ms = 0u64;
|
||||
let mut max_forward_ms = 0u64;
|
||||
let mut send_errors = 0u64;
|
||||
let mut last_log_instant = std::time::Instant::now();
|
||||
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
%addr,
|
||||
session = session_id,
|
||||
"forwarding loop started (plain)"
|
||||
);
|
||||
|
||||
loop {
|
||||
let recv_start = std::time::Instant::now();
|
||||
let pkt = match transport.recv_media().await {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
||||
info!(%addr, participant = participant_id, "connection closed: {e}");
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
|
||||
} else {
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = std::time::Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
// Log if recv gap is suspiciously large (>200ms = missed ~10 packets)
|
||||
if recv_gap_ms > 200 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
"large recv gap"
|
||||
);
|
||||
}
|
||||
|
||||
// Update per-session quality metrics if a quality report is present
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
// Get current list of other participants
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
lock_ms,
|
||||
"slow room_mgr lock"
|
||||
);
|
||||
}
|
||||
|
||||
// Forward to all others
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
ParticipantSender::Quic(t) => {
|
||||
let _ = t.send_media(&pkt).await;
|
||||
if let Err(e) = t.send_media(&pkt).await {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
peer = %t.connection().remote_address(),
|
||||
total_send_errors = send_errors,
|
||||
"send_media error: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients receive raw payload bytes
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
ParticipantSender::Federation { transport, room_hash } => {
|
||||
// Send room-tagged datagram to federated peer
|
||||
let data = pkt.to_bytes();
|
||||
let mut tagged = Vec::with_capacity(8 + data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(&data);
|
||||
if let Err(e) = transport.send_raw_datagram(&tagged) {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 {
|
||||
warn!(room = %room_name, "federation forward error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
if fwd_ms > max_forward_ms {
|
||||
max_forward_ms = fwd_ms;
|
||||
}
|
||||
if fwd_ms > 50 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
fwd_ms,
|
||||
fan_out = others.len(),
|
||||
"slow forward"
|
||||
);
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
if packets_forwarded % 500 == 0 {
|
||||
|
||||
// Periodic stats log every 5 seconds
|
||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
@@ -381,14 +639,24 @@ async fn run_participant_plain(
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
fan_out,
|
||||
max_recv_gap_ms,
|
||||
max_forward_ms,
|
||||
send_errors,
|
||||
"participant stats"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
max_forward_ms = 0;
|
||||
last_log_instant = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Clean up — leave room and broadcast update to remaining participants
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
mgr.leave(&room_name, participant_id);
|
||||
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
|
||||
drop(mgr); // release lock before async broadcast
|
||||
broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trunked forwarding loop — batches outgoing packets per peer.
|
||||
@@ -404,6 +672,19 @@ async fn run_participant_trunked(
|
||||
|
||||
let addr = transport.connection().remote_address();
|
||||
let mut packets_forwarded = 0u64;
|
||||
let mut last_recv_instant = std::time::Instant::now();
|
||||
let mut max_recv_gap_ms = 0u64;
|
||||
let mut max_forward_ms = 0u64;
|
||||
let mut send_errors = 0u64;
|
||||
let mut last_log_instant = std::time::Instant::now();
|
||||
|
||||
info!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
%addr,
|
||||
session = session_id,
|
||||
"forwarding loop started (trunked)"
|
||||
);
|
||||
|
||||
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
|
||||
// transport (stable for the Arc's lifetime). We use the remote address
|
||||
@@ -425,24 +706,50 @@ async fn run_participant_trunked(
|
||||
let pkt = match result {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => {
|
||||
info!(%addr, participant = participant_id, "disconnected");
|
||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%addr, participant = participant_id, "recv error: {e}");
|
||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||
last_recv_instant = std::time::Instant::now();
|
||||
if recv_gap_ms > max_recv_gap_ms {
|
||||
max_recv_gap_ms = recv_gap_ms;
|
||||
}
|
||||
if recv_gap_ms > 200 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
recv_gap_ms,
|
||||
seq = pkt.header.seq,
|
||||
"large recv gap (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref report) = pkt.quality_report {
|
||||
metrics.update_session_quality(session_id, report);
|
||||
}
|
||||
|
||||
let lock_start = std::time::Instant::now();
|
||||
let others = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.others(&room_name, participant_id)
|
||||
};
|
||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||
if lock_ms > 10 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
lock_ms,
|
||||
"slow room_mgr lock (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
let fwd_start = std::time::Instant::now();
|
||||
let pkt_bytes = pkt.payload.len() as u64;
|
||||
for other in &others {
|
||||
match other {
|
||||
@@ -452,21 +759,51 @@ async fn run_participant_trunked(
|
||||
.entry(peer_addr)
|
||||
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
|
||||
if let Err(e) = fwd.send(&pkt).await {
|
||||
let _ = e;
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
peer = %peer_addr,
|
||||
total_send_errors = send_errors,
|
||||
"trunked send error: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
// WS clients bypass trunking — send raw payload directly
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
ParticipantSender::Federation { transport, room_hash } => {
|
||||
let data = pkt.to_bytes();
|
||||
let mut tagged = Vec::with_capacity(8 + data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(&data);
|
||||
let _ = transport.send_raw_datagram(&tagged);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
if fwd_ms > max_forward_ms {
|
||||
max_forward_ms = fwd_ms;
|
||||
}
|
||||
if fwd_ms > 50 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
fwd_ms,
|
||||
fan_out = others.len(),
|
||||
"slow forward (trunked)"
|
||||
);
|
||||
}
|
||||
|
||||
let fan_out = others.len() as u64;
|
||||
metrics.packets_forwarded.inc_by(fan_out);
|
||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||
packets_forwarded += 1;
|
||||
if packets_forwarded % 500 == 0 {
|
||||
|
||||
// Periodic stats every 5 seconds
|
||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||
let room_size = {
|
||||
let mgr = room_mgr.lock().await;
|
||||
mgr.room_size(&room_name)
|
||||
@@ -476,15 +813,30 @@ async fn run_participant_trunked(
|
||||
participant = participant_id,
|
||||
forwarded = packets_forwarded,
|
||||
room_size,
|
||||
fan_out,
|
||||
max_recv_gap_ms,
|
||||
max_forward_ms,
|
||||
send_errors,
|
||||
"participant stats (trunked)"
|
||||
);
|
||||
max_recv_gap_ms = 0;
|
||||
max_forward_ms = 0;
|
||||
last_log_instant = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
_ = flush_interval.tick() => {
|
||||
for fwd in forwarders.values_mut() {
|
||||
if let Err(e) = fwd.flush().await {
|
||||
let _ = e;
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||
warn!(
|
||||
room = %room_name,
|
||||
participant = participant_id,
|
||||
total_send_errors = send_errors,
|
||||
"trunk flush error: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,7 +849,10 @@ async fn run_participant_trunked(
|
||||
}
|
||||
|
||||
let mut mgr = room_mgr.lock().await;
|
||||
mgr.leave(&room_name, participant_id);
|
||||
if let Some((update, senders)) = mgr.leave(&room_name, participant_id) {
|
||||
drop(mgr);
|
||||
broadcast_signal(&senders, &update).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse up to the first 2 bytes of a hex session-id string into `[u8; 2]`.
|
||||
|
||||
@@ -16,6 +16,9 @@ async-trait = { workspace = true }
|
||||
serde_json = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
rcgen = "0.13"
|
||||
ed25519-dalek = { workspace = true }
|
||||
hkdf = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -6,20 +6,74 @@ use std::time::Duration;
|
||||
use quinn::crypto::rustls::QuicClientConfig;
|
||||
use quinn::crypto::rustls::QuicServerConfig;
|
||||
|
||||
/// Create a server configuration with a self-signed certificate (for testing).
|
||||
/// Create a server configuration with a self-signed certificate (random keypair).
|
||||
///
|
||||
/// Tunes QUIC transport parameters for lossy VoIP:
|
||||
/// - 30s idle timeout
|
||||
/// - 5s keep-alive interval
|
||||
/// - DATAGRAM extension enabled
|
||||
/// - Conservative flow control for bandwidth-constrained links
|
||||
/// The certificate changes on every call. Use `server_config_from_seed` for
|
||||
/// a deterministic certificate that survives relay restarts.
|
||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||
.expect("failed to generate self-signed cert");
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||
let key_der =
|
||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
||||
build_server_config(cert_der, key_der)
|
||||
}
|
||||
|
||||
/// Create a server configuration with a deterministic self-signed certificate
|
||||
/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint.
|
||||
pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) {
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
// Derive Ed25519 key bytes from seed via HKDF
|
||||
let hk = Hkdf::<Sha256>::new(None, seed);
|
||||
let mut ed_bytes = [0u8; 32];
|
||||
hk.expand(b"wzp-tls-ed25519", &mut ed_bytes)
|
||||
.expect("HKDF expand failed");
|
||||
|
||||
// Create Ed25519 signing key and export as PKCS8 DER
|
||||
let signing_key = SigningKey::from_bytes(&ed_bytes);
|
||||
let pkcs8_doc = signing_key.to_pkcs8_der()
|
||||
.expect("failed to encode Ed25519 key as PKCS8");
|
||||
let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
|
||||
.expect("failed to wrap PKCS8 DER");
|
||||
|
||||
// Create rcgen KeyPair from DER
|
||||
let key_pair = rcgen::KeyPair::from_der_and_sign_algo(
|
||||
&key_der_for_rcgen,
|
||||
&rcgen::PKCS_ED25519,
|
||||
)
|
||||
.expect("failed to create KeyPair from seed-derived Ed25519 key");
|
||||
|
||||
// Build self-signed cert with this deterministic keypair
|
||||
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
|
||||
.expect("failed to create CertificateParams");
|
||||
let cert = params.self_signed(&key_pair).expect("failed to self-sign cert");
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
|
||||
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
|
||||
.expect("failed to serialize key DER");
|
||||
|
||||
build_server_config(cert_der, key_der)
|
||||
}
|
||||
|
||||
/// Compute a hex-formatted SHA-256 fingerprint of a DER-encoded certificate.
|
||||
///
|
||||
/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons).
|
||||
pub fn tls_fingerprint(cert_der: &[u8]) -> String {
|
||||
use sha2::{Sha256, Digest};
|
||||
let hash = Sha256::digest(cert_der);
|
||||
hash.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
fn build_server_config(
|
||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
) -> (quinn::ServerConfig, Vec<u8>) {
|
||||
let mut server_crypto = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||
|
||||
@@ -22,7 +22,7 @@ pub mod path_monitor;
|
||||
pub mod quic;
|
||||
pub mod reliable;
|
||||
|
||||
pub use config::{client_config, server_config};
|
||||
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
|
||||
pub use connection::{accept, connect, create_endpoint};
|
||||
pub use path_monitor::PathMonitor;
|
||||
pub use quic::QuinnTransport;
|
||||
|
||||
@@ -136,6 +136,11 @@ impl PathMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get raw packet counts for debugging.
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
(self.total_sent, self.total_received)
|
||||
}
|
||||
|
||||
/// Estimate bandwidth in kbps from bytes received over time.
|
||||
fn estimate_bandwidth_kbps(&self) -> u32 {
|
||||
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {
|
||||
@@ -149,6 +154,27 @@ impl PathMonitor {
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Detect whether a network handoff likely occurred.
|
||||
///
|
||||
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x
|
||||
/// the EWMA-smoothed jitter average, which is characteristic of a cellular
|
||||
/// network handoff (tower switch, WiFi-to-cellular transition, etc.).
|
||||
pub fn detect_handoff(&self) -> bool {
|
||||
// We need at least two RTT observations to have a meaningful jitter value,
|
||||
// and the EWMA must be non-zero to avoid division/multiplication by zero.
|
||||
if self.jitter_ewma <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let (Some(last_rtt), Some(_)) = (self.last_rtt_ms, Some(self.rtt_ewma)) {
|
||||
// Compute the most recent instantaneous jitter (RTT deviation from EWMA)
|
||||
let instant_jitter = (last_rtt - self.rtt_ewma).abs();
|
||||
instant_jitter > self.jitter_ewma * 3.0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PathMonitor {
|
||||
|
||||
@@ -33,6 +33,29 @@ impl QuinnTransport {
|
||||
&self.connection
|
||||
}
|
||||
|
||||
/// Send raw bytes as a QUIC datagram (no MediaPacket framing).
|
||||
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
|
||||
self.connection
|
||||
.send_datagram(bytes::Bytes::copy_from_slice(data))
|
||||
.map_err(|e| TransportError::Internal(format!("datagram: {e}")))
|
||||
}
|
||||
|
||||
/// Close the QUIC connection immediately (synchronous, no async needed).
|
||||
/// The relay will detect the close and remove this participant from the room.
|
||||
pub fn close_now(&self) {
|
||||
self.connection.close(quinn::VarInt::from_u32(0), b"hangup");
|
||||
}
|
||||
|
||||
/// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor.
|
||||
pub fn feed_rtt(&self, rtt_ms: u32) {
|
||||
self.path_monitor.lock().unwrap().observe_rtt(rtt_ms);
|
||||
}
|
||||
|
||||
/// Get raw packet counts from path monitor (sent, received).
|
||||
pub fn monitor_counts(&self) -> (u64, u64) {
|
||||
self.path_monitor.lock().unwrap().counts()
|
||||
}
|
||||
|
||||
/// Get the maximum datagram payload size, if datagrams are supported.
|
||||
pub fn max_datagram_size(&self) -> Option<usize> {
|
||||
datagram::max_datagram_payload(&self.connection)
|
||||
|
||||
@@ -272,7 +272,7 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
||||
// Crypto handshake with relay
|
||||
let handshake_start = std::time::Instant::now();
|
||||
let bridge_seed = wzp_crypto::Seed::generate();
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await {
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0, None).await {
|
||||
Ok(_session) => {
|
||||
let elapsed = handshake_start.elapsed().as_secs_f64();
|
||||
state.metrics.handshake_latency.observe(elapsed);
|
||||
|
||||
115
debug/INCIDENT-2026-04-06-art-gc-sigbus.md
Normal file
115
debug/INCIDENT-2026-04-06-art-gc-sigbus.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Incident Report: SIGBUS in ART GC During Audio Thread JNI Calls
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** High — app crash (SIGBUS) mid-call
|
||||
**Status:** Root-caused, fix proposed
|
||||
**Affects:** Android 16 (API 36) devices with concurrent mark-compact GC
|
||||
|
||||
## Summary
|
||||
|
||||
The app crashes with SIGBUS (signal 7, BUS_ADRERR) during an active call. The crash occurs in ART's garbage collector or JIT compiler, NOT in our Rust native code or AudioRing buffer. Both `wzp-capture` and `wzp-playout` Kotlin threads are affected.
|
||||
|
||||
## Crash Details
|
||||
|
||||
### Crash 1: wzp-capture (18:42, after 476s of call)
|
||||
|
||||
```
|
||||
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x720009be38
|
||||
tid 19697 (wzp-capture), pid 17885 (com.wzp.phone)
|
||||
```
|
||||
|
||||
**Backtrace:**
|
||||
```
|
||||
#00 art::StackVisitor::WalkStack
|
||||
#01 art::Thread::VisitRoots
|
||||
#02 art::gc::collector::MarkCompact::ThreadFlipVisitor::Run
|
||||
#03 art::Thread::EnsureFlipFunctionStarted
|
||||
#04 CheckJNI::ReleasePrimitiveArrayElements ← JNI boundary
|
||||
#05 android_media_AudioRecord_readInArray ← AudioRecord.read()
|
||||
#09 com.wzp.audio.AudioPipeline.runCapture
|
||||
```
|
||||
|
||||
**Root cause:** ART's concurrent mark-compact GC (`MarkCompact::ThreadFlipVisitor`) is flipping thread roots while the capture thread is in the middle of a JNI call (`AudioRecord.read()`). The GC's `EnsureFlipFunctionStarted` triggers a stack walk that hits an invalid address.
|
||||
|
||||
### Crash 2: wzp-playout (19:17, mid-call)
|
||||
|
||||
```
|
||||
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x225eb98
|
||||
tid 32574 (wzp-playout), pid 32479 (com.wzp.phone)
|
||||
```
|
||||
|
||||
**Backtrace:**
|
||||
```
|
||||
#00 com.wzp.audio.AudioPipeline.runPlayout ← JIT-compiled code
|
||||
#01 art_quick_osr_stub ← On-Stack Replacement
|
||||
#02 art::jit::Jit::MaybeDoOnStackReplacement
|
||||
#03-#04 art::interpreter::ExecuteSwitchImplCpp
|
||||
```
|
||||
|
||||
**Root cause:** ART's JIT compiler performed On-Stack Replacement (OSR) on the hot playout loop. The OSR stub references a code address (`0x225eb98`) that is no longer valid — likely because the GC moved the compiled code in memory during concurrent compaction.
|
||||
|
||||
## Why This Happens
|
||||
|
||||
Android 16 introduced a new **concurrent mark-compact GC** (CMC) that moves objects in memory while other threads are running. This is safe for normal Java code because ART uses read barriers. But our audio threads have specific properties that stress this:
|
||||
|
||||
1. **`Thread.MAX_PRIORITY`** — audio threads run at the highest priority, starving the GC thread of CPU time. The GC may not complete its thread-flip before the audio thread resumes.
|
||||
|
||||
2. **Tight JNI loops** — `runCapture()` and `runPlayout()` loop every 20ms calling `AudioRecord.read()` / `AudioTrack.write()` via JNI. Each JNI transition is a GC safepoint, but the thread spends most of its time in native code where the GC can't flip it.
|
||||
|
||||
3. **Long-running JIT-compiled code** — the hot loop gets JIT-compiled and may undergo OSR. If the GC compacts memory while OSR is in progress, the stub can reference stale addresses.
|
||||
|
||||
4. **Daemon threads that never exit** — our threads are parked with `Thread.sleep(Long.MAX_VALUE)` after the call ends (to avoid the libcrypto TLS destructor crash). These zombie threads accumulate GC root scan work.
|
||||
|
||||
## Evidence This Is Not Our Bug
|
||||
|
||||
| Component | Evidence |
|
||||
|-----------|---------|
|
||||
| **AudioRing** | Not in any backtrace. All crash frames are in `libart.so` (ART runtime) |
|
||||
| **Rust native code** | `libwzp_android.so` not in any crash frame |
|
||||
| **JNI bridge** | Crash happens during `ReleasePrimitiveArrayElements` (ART internal), not during our JNI calls |
|
||||
| **Timing** | Crashes after 476s and mid-call — not during init or teardown |
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Option A: Disable concurrent GC compaction for audio threads (recommended)
|
||||
|
||||
Use `dalvik.vm.gctype` or per-thread GC pinning to prevent the mark-compact collector from moving objects referenced by audio threads.
|
||||
|
||||
**Not directly controllable from app code.** But we can reduce GC pressure:
|
||||
|
||||
### Option B: Reduce JNI transitions in audio threads
|
||||
|
||||
Instead of calling `engine.writeAudio(pcm)` / `engine.readAudio(pcm)` via JNI on every 20ms frame, batch multiple frames or use `DirectByteBuffer` to share memory without JNI array copies.
|
||||
|
||||
**Implementation:**
|
||||
- Allocate a `DirectByteBuffer` in Kotlin, share the pointer with Rust via JNI
|
||||
- Audio threads write/read directly to the buffer (no JNI call per frame)
|
||||
- Rust reads/writes from the same memory region
|
||||
- Reduces JNI transitions from 100/sec to 0/sec per audio direction
|
||||
|
||||
### Option C: Use Android's Oboe (AAudio) natively from Rust
|
||||
|
||||
Skip the Kotlin AudioRecord/AudioTrack entirely. Use Oboe (which we already have as a dependency in `wzp-android/Cargo.toml`) to create native audio streams directly from Rust. The audio callbacks run in native code with no JNI, no GC interaction, no ART.
|
||||
|
||||
This is how the project was originally designed (see `audio_android.rs` with Oboe references) before switching to Kotlin AudioRecord for simplicity.
|
||||
|
||||
**Pros:** Eliminates the entire JNI audio path. No GC interaction. Lower latency.
|
||||
**Cons:** Requires rewriting `AudioPipeline.kt` into Rust. Oboe setup is more complex.
|
||||
|
||||
### Option D: Pin audio thread objects to prevent GC movement
|
||||
|
||||
Use JNI `GetPrimitiveArrayCritical` instead of `GetShortArrayRegion` to pin the array in memory during the operation. This prevents the GC from moving the array while we're using it.
|
||||
|
||||
**Implementation:** Change `nativeWriteAudio` / `nativeReadAudio` JNI functions to use critical sections.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Short term: Option B** (DirectByteBuffer) — reduces JNI transitions without major refactoring.
|
||||
|
||||
**Long term: Option C** (Oboe from Rust) — eliminates the problem entirely. This is the architecturally correct solution and matches the original design intent.
|
||||
|
||||
## Data Files
|
||||
|
||||
- Logcat from Nothing A059 (Android 16, API 36)
|
||||
- Two crashes in the same session: 18:42 (capture, after 476s) and 19:17 (playout)
|
||||
- Both SIGBUS/BUS_ADRERR, both in ART internal frames
|
||||
175
debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md
Normal file
175
debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Incident Report: Native Crash in Capture Thread — Use-After-Free on Engine Handle
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** Critical — app crash (SIGSEGV) on call hangup
|
||||
**Status:** Root-caused, fix pending
|
||||
**Affects:** Android client only
|
||||
|
||||
## Summary
|
||||
|
||||
The app crashes with a native SIGSEGV during or shortly after call hangup. The crash occurs in JIT-compiled code inside `AudioPipeline.runCapture()`. The root cause is a use-after-free: the capture thread calls `engine.writeAudio()` via JNI after the engine's native handle has been freed by `teardown()` on the ViewModel thread.
|
||||
|
||||
## Crash Stacktrace
|
||||
|
||||
```
|
||||
04-06 13:05:42.707 F DEBUG: #09 pc 000000000250696c /memfd:jit-cache (deleted) (com.wzp.audio.AudioPipeline.runCapture+3228)
|
||||
04-06 13:05:42.707 F DEBUG: #14 pc 0000000000005270 <anonymous:730900d000> (com.wzp.audio.AudioPipeline.start$lambda$0+0)
|
||||
04-06 13:05:42.708 F DEBUG: #19 pc 00000000000044cc <anonymous:730900d000> (com.wzp.audio.AudioPipeline.$r8$lambda$0rYcivupwvyN4SgBXhsroKmTlo8+0)
|
||||
04-06 13:05:42.708 F DEBUG: #24 pc 00000000000042e4 <anonymous:730900d000> (com.wzp.audio.AudioPipeline$$ExternalSyntheticLambda0.run+0)
|
||||
```
|
||||
|
||||
This is a tombstone (signal crash), not a Java exception. The `F DEBUG` tag indicates a native crash handler (debuggerd) captured the signal.
|
||||
|
||||
## Root Cause
|
||||
|
||||
### The Race Condition
|
||||
|
||||
Two threads operate on the engine concurrently without synchronization:
|
||||
|
||||
**Thread 1: `wzp-capture` (AudioRecord thread, MAX_PRIORITY)**
|
||||
```kotlin
|
||||
// AudioPipeline.runCapture() — runs in a tight loop
|
||||
while (running) {
|
||||
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
|
||||
if (read > 0) {
|
||||
engine.writeAudio(pcm) // <-- JNI call to native engine
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Thread 2: ViewModel/UI thread (normal priority)**
|
||||
```kotlin
|
||||
// CallViewModel.teardown()
|
||||
stopAudio() // sets AudioPipeline.running = false
|
||||
engine?.stopCall() // tells Rust to stop
|
||||
engine?.destroy() // frees native memory, sets nativeHandle = 0L
|
||||
engine = null
|
||||
```
|
||||
|
||||
### The Kotlin Guard is Insufficient
|
||||
|
||||
`WzpEngine.writeAudio()` has a guard:
|
||||
```kotlin
|
||||
fun writeAudio(pcm: ShortArray): Int {
|
||||
if (nativeHandle == 0L) return 0 // check
|
||||
return nativeWriteAudio(nativeHandle, pcm) // use
|
||||
}
|
||||
```
|
||||
|
||||
This is a **TOCTOU (time-of-check/time-of-use) race**:
|
||||
1. Capture thread checks `nativeHandle != 0L` → true
|
||||
2. ViewModel thread calls `destroy()`, which calls `nativeDestroy(handle)` then sets `nativeHandle = 0L`
|
||||
3. Capture thread calls `nativeWriteAudio(handle, pcm)` with the now-freed handle
|
||||
4. The JNI function dereferences `handle` as a pointer → **SIGSEGV**
|
||||
|
||||
The same race exists for `readAudio()` on the `wzp-playout` thread.
|
||||
|
||||
### Why `stopAudio()` Doesn't Prevent This
|
||||
|
||||
`AudioPipeline.stop()` sets `running = false` but does **NOT join or wait** for the threads:
|
||||
```kotlin
|
||||
fun stop() {
|
||||
running = false
|
||||
// Don't join — threads are parked as daemons to avoid native TLS crash
|
||||
captureThread = null
|
||||
playoutThread = null
|
||||
}
|
||||
```
|
||||
|
||||
The threads are intentionally not joined because of a separate bug: exiting a JNI-calling thread triggers a `SIGSEGV in OPENSSL_free` due to libcrypto TLS destructors on Android. The threads instead "park" with `Thread.sleep(Long.MAX_VALUE)` after the loop exits.
|
||||
|
||||
But the problem is the **window between `running = false` and the thread actually checking it**. The capture thread may be blocked in `recorder.read()` (which blocks for 20ms per frame) or in the middle of `engine.writeAudio()` when `destroy()` is called.
|
||||
|
||||
### Timeline of the Crash
|
||||
|
||||
```
|
||||
T=0ms ViewModel: stopAudio() → sets running=false
|
||||
T=0ms ViewModel: stopStatsPolling()
|
||||
T=0ms ViewModel: engine.stopCall() — Rust stops internal tasks
|
||||
T=1ms ViewModel: engine.destroy() — frees native memory
|
||||
↑ nativeHandle = 0L
|
||||
|
||||
T=0-20ms Capture thread: still in recorder.read() or writeAudio()
|
||||
→ if in writeAudio(), the nativeHandle check passed BEFORE destroy()
|
||||
→ JNI dereferences freed pointer → SIGSEGV
|
||||
```
|
||||
|
||||
## Affected Code
|
||||
|
||||
### Files with the race
|
||||
|
||||
| File | Line(s) | Issue |
|
||||
|------|---------|-------|
|
||||
| `android/.../WzpEngine.kt` | 107-108, 116-117 | TOCTOU on `nativeHandle` in `writeAudio()` / `readAudio()` |
|
||||
| `android/.../CallViewModel.kt` | 257-262 | `stopAudio()` + `destroy()` without waiting for audio threads to quiesce |
|
||||
| `android/.../AudioPipeline.kt` | 80-82 | `stop()` doesn't synchronize with running threads |
|
||||
|
||||
### Files with the thread parking workaround
|
||||
|
||||
| File | Line(s) | Context |
|
||||
|------|---------|---------|
|
||||
| `android/.../AudioPipeline.kt` | 57-58, 69-70 | Threads parked after loop exit to avoid libcrypto TLS crash |
|
||||
| `android/.../AudioPipeline.kt` | 96-101 | `parkThread()` — `Thread.sleep(Long.MAX_VALUE)` |
|
||||
|
||||
## Constraints for the Fix
|
||||
|
||||
1. **Cannot join audio threads** — joining triggers a separate SIGSEGV in `OPENSSL_free` when the thread's TLS destructors fire (documented in `AudioPipeline.kt` comments). The parking workaround must be preserved.
|
||||
|
||||
2. **Must guarantee no JNI calls after `destroy()`** — the native handle is a raw pointer; any dereference after free is undefined behavior.
|
||||
|
||||
3. **Must not add blocking waits on the UI thread** — `teardown()` runs on the ViewModel thread which must remain responsive.
|
||||
|
||||
4. **The `@Volatile running` flag is necessary but not sufficient** — it prevents new loop iterations but doesn't help with in-flight JNI calls.
|
||||
|
||||
5. **Both `writeAudio` and `readAudio` have the same race** — the fix must cover both the capture and playout paths.
|
||||
|
||||
## Reproduction
|
||||
|
||||
The crash is timing-dependent. It's most likely to occur when:
|
||||
- The capture thread is in the middle of a `writeAudio()` JNI call when `destroy()` is called
|
||||
- More likely on slower devices or under CPU pressure (GC, thermal throttling)
|
||||
- Can happen on every hangup, but only crashes ~10-30% of the time due to the timing window
|
||||
|
||||
## Analysis of Possible Fix Approaches
|
||||
|
||||
### Approach A: Add a synchronization gate in the JNI bridge
|
||||
|
||||
Use a `ReentrantReadWriteLock` or `AtomicBoolean` in `WzpEngine.kt`:
|
||||
- Audio threads acquire a read lock / check the flag before JNI calls
|
||||
- `destroy()` acquires a write lock / sets the flag and waits for in-flight calls to drain
|
||||
|
||||
**Pro:** Clean, solves the race directly.
|
||||
**Con:** Adding a lock to the audio hot path (every 20ms). `ReentrantReadWriteLock` is not lock-free. However, the read-lock path is uncontended 99.99% of the time (write-lock only during destroy), so contention is negligible.
|
||||
|
||||
### Approach B: Defer `destroy()` until audio threads have stopped
|
||||
|
||||
Instead of calling `destroy()` in `teardown()`, set a flag and have the audio threads call `destroy()` after they exit the loop (before parking).
|
||||
|
||||
**Pro:** No locks on hot path.
|
||||
**Con:** Complex lifecycle — which thread calls destroy? What if both threads race to destroy? Need a `CountDownLatch` or similar.
|
||||
|
||||
### Approach C: Make the JNI handle atomically invalidated
|
||||
|
||||
Use `AtomicLong` for `nativeHandle` and use `compareAndExchange` in `destroy()` + `getAndCheck` pattern in audio calls.
|
||||
|
||||
**Pro:** Lock-free.
|
||||
**Con:** Still has a TOCTOU window — the thread can load the handle, then it gets CAS'd to 0, then the thread uses the stale handle. Doesn't fully solve the race without combining with a reference count or epoch.
|
||||
|
||||
### Approach D: Introduce a destroy latch
|
||||
|
||||
Add a `CountDownLatch(1)` that audio threads wait on before parking. `teardown()` sets `running=false`, then `await`s the latch (with timeout), then calls `destroy()`. Each audio thread counts down the latch after exiting the loop.
|
||||
|
||||
Actually this needs a `CountDownLatch(2)` — one for each thread (capture + playout).
|
||||
|
||||
**Pro:** Guarantees no in-flight JNI calls at destroy time. No locks on hot path.
|
||||
**Con:** `teardown()` blocks for up to one frame duration (~20ms) waiting for threads to exit their loops. Acceptable for a hangup path.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Approach D (destroy latch)** is the cleanest. The 20ms worst-case wait is imperceptible on the hangup path, and it provides a hard guarantee that no JNI calls are in flight when `destroy()` runs. Combined with the existing `running` volatile flag, the audio threads exit their loops within one frame and count down the latch.
|
||||
|
||||
If the latch times out (e.g., AudioRecord.read() is stuck), `destroy()` proceeds anyway — the `panic::catch_unwind` in the JNI bridge will catch the invalid access as a panic rather than a SIGSEGV (though this is best-effort; a true SIGSEGV from freed memory is not catchable).
|
||||
|
||||
## Data Files
|
||||
|
||||
The crash was captured from the Nothing A059 device at 13:05:42 on 2026-04-06. The tombstone is in the device's `/data/tombstones/` directory. The logcat output shows the crash frames.
|
||||
166
debug/INCIDENT-2026-04-06-playout-ring-desync.md
Normal file
166
debug/INCIDENT-2026-04-06-playout-ring-desync.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Incident Report: Playout Ring Buffer Cursor Desync — Bidirectional Audio Loss
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** Critical — causes 10-16 seconds of complete bidirectional silence mid-call
|
||||
**Status:** Root-caused, fix pending
|
||||
**Affects:** All clients using `AudioRing` (Android, potentially desktop)
|
||||
|
||||
## Summary
|
||||
|
||||
Both participants in a call experience simultaneous, prolonged audio silence (10-16 seconds) despite the QUIC transport, relay, and Rust codec pipeline all functioning normally. The root cause is a cursor desynchronization in the lock-free SPSC ring buffer (`AudioRing`) that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
|
||||
|
||||
## How We Know It's the Ring Buffer
|
||||
|
||||
### Evidence that eliminates other components
|
||||
|
||||
| Component | Evidence it's healthy | Source |
|
||||
|-----------|----------------------|--------|
|
||||
| **QUIC send path** | `frames_dropped=0, send_errors=0` on both clients | Engine send stats log |
|
||||
| **QUIC recv path** | `max_recv_gap_ms=82, recv_errors=0` — no gaps >82ms | Engine recv stats log |
|
||||
| **Relay forwarding** | `max_forward_ms=0, send_errors=0` in previous relay-instrumented test | Relay debug logging |
|
||||
| **Opus codec** | `frames_decoded=2442` over 51.9s = 47 frames/sec (correct for 20ms) | Final stats JSON |
|
||||
| **FEC** | `fec_recovered=4870` — FEC working normally | Final stats JSON |
|
||||
| **Audio capture** | Pixel 6 capture has 0% silence; Nothing has gaps but those are expected mic pauses | capture_rms.csv |
|
||||
|
||||
### Evidence pointing to the ring buffer
|
||||
|
||||
1. **Both clients go silent at the exact same wall-clock moment (26.66s into call)** — rules out per-device issues; the common factor is the relay, but the relay was proven healthy in prior tests.
|
||||
|
||||
2. **`playout_avail=8640` at stats dump time** — the playout ring reports 8640 samples available (180ms, nearly full at the 9600 capacity). The recv task believes it has successfully written data into the ring. But the AudioTrack playout thread is reading silence (RMS=0 for 12+ seconds).
|
||||
|
||||
3. **Recv task continued receiving packets with no gaps** — `max_recv_gap_ms=82` across the entire call. The decoded audio was written to the ring continuously.
|
||||
|
||||
4. **Silence starts and ends cleanly** — the transition from audio → silence happens within a single 20ms frame (frame 1332: rms=101, frame 1333: rms=0). This is not network degradation (which shows gradual quality loss). It's a discrete state change — the reader suddenly stops seeing data.
|
||||
|
||||
5. **Recovery is also discrete** — at ~38.8s (Sharp Hawk) and ~42.7s (Pixel 6), audio snaps back with high-energy frames (rms=3296+). Not a gradual reconnection.
|
||||
|
||||
## The Ring Buffer Code
|
||||
|
||||
**File:** `crates/wzp-android/src/audio_ring.rs`
|
||||
|
||||
```rust
|
||||
const RING_CAPACITY: usize = 960 * 10; // 9600 samples = 200ms at 48kHz
|
||||
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
write_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
|
||||
read_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
|
||||
}
|
||||
```
|
||||
|
||||
### `available()` — how many samples can be read
|
||||
```rust
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Acquire);
|
||||
w.wrapping_sub(r) // relies on usize wrapping arithmetic
|
||||
}
|
||||
```
|
||||
|
||||
### `write()` — producer (Rust recv task thread, inside tokio block_on)
|
||||
```rust
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
// ... write samples at (w + i) % RING_CAPACITY ...
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
|
||||
// If we overwrote unread data, advance read_pos
|
||||
if self.available() > RING_CAPACITY {
|
||||
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
|
||||
self.read_pos.store(new_read, Ordering::Release);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `read()` — consumer (Kotlin AudioTrack JVM thread, via JNI)
|
||||
```rust
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let avail = self.available();
|
||||
let count = out.len().min(avail);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
// ... read samples at (r + i) % RING_CAPACITY ...
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
```
|
||||
|
||||
## Suspected Failure Modes
|
||||
|
||||
### 1. Writer advances `read_pos` while reader is mid-read (data race)
|
||||
|
||||
The `write()` method at lines 68-72 modifies `read_pos` from the writer thread when it detects overflow. But the `read()` method on the consumer thread also modifies `read_pos`. This violates the SPSC contract — `read_pos` is supposed to be owned by the consumer.
|
||||
|
||||
**Scenario:**
|
||||
1. Reader loads `read_pos = R` (line 82)
|
||||
2. Writer detects overflow, stores `read_pos = R'` (line 71) where `R' > R`
|
||||
3. Reader finishes reading, stores `read_pos = R + count` (line 88) — **overwrites** the writer's `R'` with a stale, smaller value
|
||||
|
||||
After step 3, the ring's `read_pos` has gone backwards. Now `available()` returns `write_pos.wrapping_sub(old_read_pos)` which is larger than `RING_CAPACITY`. Every subsequent `write()` call hits the overflow branch and keeps advancing `read_pos`, but the reader keeps overwriting it back. The ring is in a corrupted state where the reader and writer are fighting over `read_pos`.
|
||||
|
||||
### 2. `wrapping_sub` returns astronomically large values
|
||||
|
||||
`available()` uses `w.wrapping_sub(r)`. On a 64-bit platform, if due to the race above `r > w`, `wrapping_sub` returns `usize::MAX - (r - w) + 1` — an enormous number. The `read()` method caps this with `out.len().min(avail)` so it reads `out.len()` samples. But those samples are from indices calculated as `(r + i) % RING_CAPACITY` which wraps correctly. The samples read would be whatever was in the buffer at those positions — potentially stale/old data, or zeros from initialization.
|
||||
|
||||
However, the playout RMS CSV shows clean zeros (RMS=0), not garbage. This suggests the ring is returning the zeroed-out initial buffer contents, meaning `read_pos` has jumped far ahead of `write_pos`, pointing to memory that was never written to (or was written long ago and has since been zeroed by the overflow advance logic).
|
||||
|
||||
### 3. Why silence lasts exactly 12-16 seconds
|
||||
|
||||
After the desync, each `write()` call (every 20ms when a packet is decoded) enters the overflow branch and resets `read_pos`. But the reader immediately overwrites it back in its next `read()` call. This tug-of-war continues until one of two things happens:
|
||||
- The cursors happen to realign through wrapping arithmetic
|
||||
- A timing coincidence where the writer's store to `read_pos` happens to "win" the race
|
||||
|
||||
The 12-16 second duration is non-deterministic and depends on exact thread scheduling and cursor values.
|
||||
|
||||
## Reproduction Pattern
|
||||
|
||||
The bug manifests after roughly 25-30 seconds of a call. This timing is suspicious:
|
||||
- At 48kHz mono, 20ms frames = 50 frames/sec
|
||||
- Each decoded frame writes 960 samples to the ring
|
||||
- After 25 seconds: `write_pos ≈ 25 * 50 * 960 = 1,200,000`
|
||||
- The ring capacity is 9600, so `write_pos` has wrapped around `RING_CAPACITY` about 125 times
|
||||
|
||||
The wrapping of the monotonic cursors past certain thresholds, combined with the reader/writer `read_pos` race, likely triggers the desync at this scale.
|
||||
|
||||
## Data Files
|
||||
|
||||
All data from two independent test sessions (3 calls total) is in `/workspace/wzp/debug/`:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `wzp_debug_20260406_120546.zip` | Sharp Hawk (Nothing A059) — 51.9s call |
|
||||
| `wzp_debug_20260406_120549.zip` | Bright Viper (Pixel 6) — 51.9s call |
|
||||
| `wzp_debug_20260406_111733.zip` | Sharp Hawk — earlier 72.0s call, same pattern |
|
||||
| `wzp_debug_20260406_111735.zip` | Bright Viper — earlier 72.0s call, same pattern |
|
||||
| `wzp_debug_20260406_105858.zip` | First session (pre-logging fix), 39.8s call |
|
||||
| `wzp_debug_20260406_105900.zip` | First session, 39.7s call |
|
||||
|
||||
### Key fields in each zip
|
||||
|
||||
- `meta.txt` — device, duration, final stats JSON
|
||||
- `playout_rms.csv` — per-frame (20ms) RMS of AudioTrack output; silence = RMS 0
|
||||
- `capture_rms.csv` — per-frame RMS of AudioRecord input
|
||||
- `logcat.txt` — Android logcat filtered to WZP + audio tags
|
||||
|
||||
### How to reproduce the analysis
|
||||
|
||||
```python
|
||||
import csv
|
||||
with open("playout_rms.csv") as f:
|
||||
for row in csv.DictReader(f):
|
||||
if int(row['rms']) == 0 and int(row['time_ms']) > 2000:
|
||||
print(f"SILENCE at {row['time_ms']}ms")
|
||||
```
|
||||
|
||||
## Affected Code
|
||||
|
||||
- `crates/wzp-android/src/audio_ring.rs` — the `AudioRing` struct, specifically the `write()` method's overflow handling that mutates `read_pos` from the producer thread
|
||||
- Any client using `AudioRing` for playout (currently only Android; desktop uses `cpal` directly)
|
||||
|
||||
## Constraints for the Fix
|
||||
|
||||
1. Must remain lock-free — AudioTrack thread runs at `Thread.MAX_PRIORITY` and cannot block
|
||||
2. Must handle overflow gracefully — if the reader falls behind, old audio should be dropped, not cause a desync
|
||||
3. The writer (Rust recv task) and reader (Kotlin AudioTrack via JNI) run on different threads with different scheduling priorities
|
||||
4. Ring capacity is 200ms which is tight — any fix must not increase latency significantly
|
||||
5. The `write_pos` and `read_pos` are the only synchronization mechanism (no mutex, no condvar)
|
||||
123
debug/INCIDENT-2026-04-06-send-task-crash.md
Normal file
123
debug/INCIDENT-2026-04-06-send-task-crash.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Incident Report: Send Task Fatal Exit on QUIC Congestion
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Severity:** High — causes complete audio loss mid-call
|
||||
**Status:** Fixed in Android client, **pending fix in desktop client and web client**
|
||||
|
||||
## Summary
|
||||
|
||||
A QUIC congestion event causes `send_datagram()` to return `Err(Blocked)`. The send task treats this as a fatal error and exits, which kills the entire call via `tokio::select!`. Audio becomes one-way (recv still works briefly) then dies completely.
|
||||
|
||||
## Root Cause
|
||||
|
||||
In the engine's send loop (`run_call` function), `transport.send_media()` errors were handled with `break`:
|
||||
|
||||
```rust
|
||||
// BEFORE (broken)
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
error!("send error: {e}");
|
||||
break; // <-- kills send task, which kills everything
|
||||
}
|
||||
```
|
||||
|
||||
Quinn's `send_datagram()` is synchronous and returns `Err(SendDatagramError::Blocked)` when the QUIC congestion window is full. This is a **transient condition** — the window opens again once ACKs arrive. But the `break` kills the send task, and since all tasks run under `tokio::select!`, the recv task, stats task, and signal task all die too.
|
||||
|
||||
### Why it manifests as "intermittent disconnections"
|
||||
|
||||
- Mobile networks have brief congestion spikes (cell tower handoff, WiFi interference)
|
||||
- A single spike fills the QUIC congestion window
|
||||
- One `Blocked` error → send task exits → `select!` cancels recv → complete silence
|
||||
- The QUIC connection stays open (no error logged), so stats polling continues showing stale data
|
||||
- From the user's perspective: audio drops for 5-20 seconds then "maybe comes back" (it doesn't — they're hearing cached playout ring drain)
|
||||
|
||||
### Evidence from debug reports
|
||||
|
||||
**Relay logs** confirmed the relay was healthy:
|
||||
- `max_forward_ms=0` — relay forwards instantly
|
||||
- `send_errors=0` — no relay-side failures
|
||||
- The relay saw `large recv gap` warnings on participant 1 (Nothing A059): 722ms → 814ms → 1778ms → 3500ms → 6091ms — the client progressively stopped sending
|
||||
|
||||
**Client stats** confirmed:
|
||||
- `frames_encoded` kept incrementing (Opus encoder running)
|
||||
- `frames_decoded` froze at a fixed value (recv task died)
|
||||
- `fec_recovered` froze simultaneously
|
||||
- RTT, loss, jitter all frozen (stats task died)
|
||||
|
||||
## Fix Applied
|
||||
|
||||
### Android client (`crates/wzp-android/src/engine.rs`)
|
||||
|
||||
```rust
|
||||
// AFTER (fixed)
|
||||
if let Err(e) = transport.send_media(&source_pkt).await {
|
||||
send_errors += 1;
|
||||
frames_dropped += 1;
|
||||
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
|
||||
warn!(seq = s, send_errors, frames_dropped,
|
||||
"send_media error (dropping packet): {e}");
|
||||
last_send_error_log = Instant::now();
|
||||
}
|
||||
continue; // <-- drop packet, keep going
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern applied to FEC repair packet sends.
|
||||
|
||||
Recv task also hardened: transient errors (non-closed/reset) are now logged and survived rather than causing exit.
|
||||
|
||||
Added periodic health logging to both tasks (5-second intervals):
|
||||
- Send: `frames_sent`, `frames_dropped`, `send_errors`, `ring_avail`
|
||||
- Recv: `frames_decoded`, `fec_recovered`, `recv_errors`, `max_recv_gap_ms`, `playout_avail`
|
||||
|
||||
### Relay (`crates/wzp-relay/src/room.rs`)
|
||||
|
||||
Added debug logging to both plain and trunked forwarding loops:
|
||||
- Per-recv gap tracking (warns on >200ms gaps)
|
||||
- Room manager lock contention tracking (warns on >10ms)
|
||||
- Forward latency tracking (warns on >50ms)
|
||||
- Send error counting with peer identification
|
||||
- 5-second periodic stats with all above metrics
|
||||
|
||||
## Affected Clients — FIX REQUIRED
|
||||
|
||||
### Desktop client (`crates/wzp-client/src/cli.rs`)
|
||||
|
||||
**Lines 345-348:**
|
||||
```rust
|
||||
if let Err(e) = transport.send_media(pkt).await {
|
||||
error!("send error: {e}");
|
||||
break; // <-- SAME BUG
|
||||
}
|
||||
```
|
||||
|
||||
**Lines 431-434:**
|
||||
```rust
|
||||
if let Err(e) = send_transport.send_media(pkt).await {
|
||||
error!("send error: {e}");
|
||||
return; // <-- SAME BUG
|
||||
}
|
||||
```
|
||||
|
||||
Both need the same continue-on-error pattern.
|
||||
|
||||
### Web client (`crates/wzp-web/src/main.rs`)
|
||||
|
||||
Needs audit — WebSocket transport may have different error semantics but same pattern should be checked.
|
||||
|
||||
## Testing
|
||||
|
||||
After fix, a congestion event will:
|
||||
1. Log warnings with packet counts: `send_media error (dropping packet): Blocked`
|
||||
2. Drop affected packets (brief audio glitch — ~20-100ms)
|
||||
3. Resume normal sending once congestion window opens
|
||||
4. FEC on the receiver side will recover most dropped packets
|
||||
5. Call continues uninterrupted
|
||||
|
||||
## Timeline
|
||||
|
||||
- 10:37 — First crash observed (LinearProgressIndicator compose bug masked investigation)
|
||||
- 10:58 — Debug reports collected, decoded stall pattern identified
|
||||
- 11:16 — Relay debug logging deployed, confirmed relay is clean
|
||||
- 11:17 — Second debug reports collected, send gaps correlated with relay recv gaps
|
||||
- 11:30 — Root cause identified: `break` on `send_media` error in send task
|
||||
- 11:45 — Fix applied and deployed
|
||||
201
docs/PRD-adaptive-quality.md
Normal file
201
docs/PRD-adaptive-quality.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# PRD: Adaptive Quality Control (Auto Codec)
|
||||
|
||||
## Problem
|
||||
|
||||
When a user selects "Auto" quality, the system currently just starts at Opus 24k (GOOD) and never changes. There is no runtime adaptation — if the network degrades mid-call, audio breaks up instead of gracefully stepping down to a lower bitrate codec. Conversely, if the network is excellent, the user stays on 24k when they could have studio-quality 64k.
|
||||
|
||||
The relay already sends `QualityReport` messages with loss % and RTT, and a `QualityAdapter` exists in `call.rs` that classifies network conditions into GOOD/DEGRADED/CATASTROPHIC — but none of this is wired into the Android or desktop engines.
|
||||
|
||||
## Solution
|
||||
|
||||
Wire the existing `QualityAdapter` into both engines so that "Auto" mode continuously monitors network quality and switches codecs mid-call. The full quality range should be used:
|
||||
|
||||
```
|
||||
Excellent network → Studio 64k (best quality)
|
||||
Good network → Opus 24k (default)
|
||||
Degraded network → Opus 6k (lower bitrate, more FEC)
|
||||
Poor network → Codec2 3.2k (vocoder, heavy FEC)
|
||||
Catastrophic → Codec2 1.2k (minimum viable voice)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
Relay ──────────► │ QualityReport │ loss %, RTT, jitter
|
||||
│ (every ~1s) │
|
||||
└────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ QualityAdapter │ classify + hysteresis
|
||||
│ (3-report window) │
|
||||
└────────┬────────────┘
|
||||
│ recommend new profile
|
||||
▼
|
||||
┌──────────────┴──────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ Encoder │ │ Decoder │
|
||||
│ set_profile() │ │ (auto-switch │
|
||||
│ + FEC update │ │ already works)│
|
||||
└────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
## Existing Infrastructure
|
||||
|
||||
### What already exists (in `crates/wzp-client/src/call.rs`)
|
||||
|
||||
1. **`QualityAdapter`** (lines 97-196):
|
||||
- Sliding window of `QualityReport` messages
|
||||
- `classify()`: loss > 15% or RTT > 200ms → CATASTROPHIC, loss > 5% or RTT > 100ms → DEGRADED, else → GOOD
|
||||
- `should_switch()`: hysteresis — requires 3 consecutive reports recommending the same profile before switching
|
||||
- Prevents oscillation between profiles
|
||||
|
||||
2. **`QualityReport`** (in `wzp-proto/src/packet.rs`):
|
||||
- Sent by relay piggy-backed on media packets
|
||||
- Fields: `loss_pct` (u8, 0-255 scaled), `rtt_4ms` (u8, RTT in 4ms units), `jitter_ms`, `bitrate_cap_kbps`
|
||||
|
||||
3. **`CallEncoder::set_profile()`** / **`CallDecoder` auto-switch**:
|
||||
- Encoder can switch codec mid-stream
|
||||
- Decoder already auto-detects incoming codec from packet headers
|
||||
|
||||
### What's missing
|
||||
|
||||
1. **QualityReport ingestion** — neither Android engine nor desktop engine reads quality reports from the relay
|
||||
2. **Profile switch loop** — no periodic check that feeds reports to `QualityAdapter` and applies recommended switches
|
||||
3. **Upward adaptation** — `QualityAdapter` only classifies into 3 tiers (GOOD/DEGRADED/CATASTROPHIC). Needs extension to recommend studio tiers when conditions are excellent (loss < 1%, RTT < 50ms)
|
||||
4. **Notification to UI** — when quality changes, the UI should show the current active codec
|
||||
|
||||
## Requirements
|
||||
|
||||
### Phase 1: Basic Adaptive (3-tier)
|
||||
|
||||
**Both Android and Desktop:**
|
||||
|
||||
1. **Ingest QualityReports**: In the recv loop, extract `quality_report` from incoming `MediaPacket`s when present. Feed to `QualityAdapter`.
|
||||
|
||||
2. **Periodic quality check**: Every 1 second (or on each QualityReport), call `adapter.should_switch(¤t_profile)`. If it returns `Some(new_profile)`:
|
||||
- Switch the encoder: `encoder.set_profile(new_profile)`
|
||||
- Update FEC encoder: `fec_enc = create_encoder(&new_profile)`
|
||||
- Update frame size if changed (e.g., 20ms → 40ms)
|
||||
- Log the switch
|
||||
|
||||
3. **Frame size adaptation on switch**: When switching from 20ms to 40ms frames (or vice versa):
|
||||
- Android: update `frame_samples` variable, resize `capture_buf`
|
||||
- Desktop: same — the send loop reads `frame_samples` dynamically
|
||||
|
||||
4. **UI indicator**: Show current active codec in the call screen stats line.
|
||||
- Android: add to `CallStats` and display in stats text
|
||||
- Desktop: add to `get_status` response and display in stats div
|
||||
|
||||
5. **Only in Auto mode**: Adaptive switching should only happen when the user selected "Auto". If they manually selected a profile, respect their choice.
|
||||
|
||||
### Phase 2: Extended Range (5-tier)
|
||||
|
||||
Extend `QualityAdapter::classify()` to use the full codec range:
|
||||
|
||||
| Condition | Profile | Codec |
|
||||
|-----------|---------|-------|
|
||||
| loss < 1% AND RTT < 30ms | STUDIO_64K | Opus 64k |
|
||||
| loss < 1% AND RTT < 50ms | STUDIO_48K | Opus 48k |
|
||||
| loss < 2% AND RTT < 80ms | STUDIO_32K | Opus 32k |
|
||||
| loss < 5% AND RTT < 100ms | GOOD | Opus 24k |
|
||||
| loss < 15% AND RTT < 200ms | DEGRADED | Opus 6k |
|
||||
| loss >= 15% OR RTT >= 200ms | CATASTROPHIC | Codec2 1.2k |
|
||||
|
||||
With hysteresis:
|
||||
- **Downgrade**: 3 consecutive reports (fast reaction to degradation)
|
||||
- **Upgrade**: 5 consecutive reports (slow, cautious improvement)
|
||||
- **Studio upgrade**: 10 consecutive reports (very conservative — avoid bouncing to 64k on brief good patches)
|
||||
|
||||
### Phase 3: Bandwidth Probing
|
||||
|
||||
Rather than relying solely on loss/RTT:
|
||||
1. Start at GOOD
|
||||
2. After 10 seconds of stable call, probe upward by switching to STUDIO_32K
|
||||
3. If no quality degradation after 5 seconds, probe to STUDIO_48K
|
||||
4. If degradation detected, immediately fall back
|
||||
5. This discovers the true available bandwidth rather than guessing from loss stats
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Android (`crates/wzp-android/src/engine.rs`)
|
||||
|
||||
```rust
|
||||
// In the recv loop, after decoding:
|
||||
if let Some(ref qr) = pkt.quality_report {
|
||||
quality_adapter.ingest(qr);
|
||||
}
|
||||
|
||||
// Periodic check (every 50 frames ≈ 1 second):
|
||||
if auto_profile && frames_decoded % 50 == 0 {
|
||||
if let Some(new_profile) = quality_adapter.should_switch(¤t_profile) {
|
||||
info!(from = ?current_profile.codec, to = ?new_profile.codec, "auto: switching quality");
|
||||
let _ = encoder_ref.lock().set_profile(new_profile);
|
||||
fec_enc_ref.lock() = create_encoder(&new_profile);
|
||||
current_profile = new_profile;
|
||||
frame_samples = frame_samples_for(&new_profile);
|
||||
// Resize capture buffer if needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Challenge**: The encoder is in the send task and the quality reports arrive in the recv task. Need shared state (AtomicU8 for profile index, or a channel).
|
||||
|
||||
**Recommended approach**: Use an `AtomicU8` that the recv task writes and the send task reads:
|
||||
```rust
|
||||
let pending_profile = Arc::new(AtomicU8::new(0xFF)); // 0xFF = no change
|
||||
|
||||
// Recv task: when adapter recommends switch
|
||||
pending_profile.store(new_profile_index, Ordering::Release);
|
||||
|
||||
// Send task: check at frame boundary
|
||||
let p = pending_profile.swap(0xFF, Ordering::Acquire);
|
||||
if p != 0xFF { /* apply switch */ }
|
||||
```
|
||||
|
||||
### Desktop (`desktop/src-tauri/src/engine.rs`)
|
||||
|
||||
Same pattern. The desktop engine already has separate send/recv tasks with shared atomics for mic_muted, etc. Add a `pending_profile: Arc<AtomicU8>` following the same pattern.
|
||||
|
||||
### Desktop CLI (`crates/wzp-client/src/call.rs`)
|
||||
|
||||
The `CallEncoder` already has `set_profile()`. The `CallDecoder` already auto-switches. Just need to:
|
||||
1. Add `QualityAdapter` to `CallDecoder`
|
||||
2. Feed quality reports in `ingest()`
|
||||
3. Check `should_switch()` in `decode_next()`
|
||||
4. Emit the recommendation via a callback or return value
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Local test with tc/netem**: Use Linux traffic control to simulate loss/latency:
|
||||
```bash
|
||||
# Simulate 10% loss, 150ms RTT
|
||||
tc qdisc add dev lo root netem loss 10% delay 75ms
|
||||
# Run 2 clients in auto mode, verify they switch to DEGRADED
|
||||
```
|
||||
|
||||
2. **CLI test**: Run `wzp-client --profile auto` between two instances with simulated network conditions
|
||||
|
||||
3. **Relay quality reports**: Verify the relay actually sends QualityReport messages. If it doesn't yet, that needs to be implemented first (check relay code).
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Does the relay currently send QualityReports?** If not, Phase 1 is blocked until the relay implements per-client loss/RTT tracking and report generation. The relay sees all packets and can compute loss % per sender.
|
||||
|
||||
2. **Codec2 3.2k placement**: Should auto mode use Codec2 3.2k between DEGRADED and CATASTROPHIC? It's 20ms frames (lower latency than Opus 6k's 40ms) but speech-only quality.
|
||||
|
||||
3. **Cross-client adaptation**: If client A is on GOOD and client B auto-adapts to CATASTROPHIC, client A still sends Opus 24k. Client B can decode it fine (auto-switch on recv). But should A also be told to lower quality to save B's bandwidth? This requires signaling between clients.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Phase | Scope | Effort | Dependency |
|
||||
|-------|-------|--------|------------|
|
||||
| 0 | Verify relay sends QualityReports | 0.5 day | None |
|
||||
| 1a | Wire QualityAdapter in Android engine | 1 day | Phase 0 |
|
||||
| 1b | Wire QualityAdapter in desktop engine | 1 day | Phase 0 |
|
||||
| 1c | UI indicator (current codec) | 0.5 day | Phase 1a/1b |
|
||||
| 2 | Extended 5-tier classification | 0.5 day | Phase 1 |
|
||||
| 3 | Bandwidth probing | 2 days | Phase 2 |
|
||||
170
docs/PRD-relay-federation.md
Normal file
170
docs/PRD-relay-federation.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# PRD: Relay Federation (Multi-Relay Mesh)
|
||||
|
||||
## Problem
|
||||
|
||||
Currently all participants in a call must connect to the same relay. This creates:
|
||||
- **Single point of failure** — if the relay goes down, the entire call drops
|
||||
- **Geographic latency** — users far from the relay get high RTT
|
||||
- **Capacity limits** — one relay handles all traffic
|
||||
|
||||
Users should be able to connect to their nearest/preferred relay and still talk to users on other relays, as long as the relays are federated.
|
||||
|
||||
## Prerequisite: Fix Relay Identity Persistence
|
||||
|
||||
### Bug: TLS certificate regenerates on every restart
|
||||
|
||||
**Root cause:** `wzp-transport/src/config.rs:17` calls `rcgen::generate_simple_self_signed()` which creates a new keypair every time. The relay's Ed25519 identity seed IS persisted to `~/.wzp/relay-identity`, but the TLS certificate is not derived from it.
|
||||
|
||||
**Impact:** Clients see a different server fingerprint after every relay restart, triggering the "Server Key Changed" warning. This also breaks federation since relays identify each other by certificate fingerprint.
|
||||
|
||||
**Fix:** Derive the TLS certificate from the persisted relay seed:
|
||||
1. Add `server_config_from_seed(seed: &[u8; 32])` to `wzp-transport`
|
||||
2. Use the seed to create a deterministic keypair (e.g., derive an ECDSA key via HKDF from the Ed25519 seed)
|
||||
3. Generate a self-signed cert with that keypair — same seed = same cert = same fingerprint
|
||||
4. The relay passes its loaded seed to `server_config_from_seed()` instead of `server_config()`
|
||||
|
||||
**Effort:** 0.5 day
|
||||
|
||||
## Federation Design
|
||||
|
||||
### Core Concept
|
||||
|
||||
Two or more relays form a **federation mesh**. Each relay is an independent SFU. When relays are configured to trust each other, they bridge rooms with matching names — participants on relay A in room "podcast" hear participants on relay B in room "podcast" as if everyone were on the same relay.
|
||||
|
||||
### Configuration
|
||||
|
||||
Each relay reads a YAML config file (e.g., `~/.wzp/relay.yaml` or `--config relay.yaml`):
|
||||
|
||||
```yaml
|
||||
# Relay identity (auto-generated if missing)
|
||||
listen: 0.0.0.0:4433
|
||||
|
||||
# Federation peers — other relays we trust and bridge rooms with
|
||||
# Both sides must configure each other for federation to work
|
||||
peers:
|
||||
- url: "193.180.213.68:4433"
|
||||
fingerprint: "a5d6:e3c6:5ae7:185c:4eb1:af89:daed:4a43"
|
||||
label: "Pangolin EU"
|
||||
|
||||
- url: "10.0.0.5:4433"
|
||||
fingerprint: "7f2a:b391:0c44:..."
|
||||
label: "Office LAN"
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- Both relays must configure each other — **mutual trust** required
|
||||
- A relay that receives a connection from an unknown peer logs: `"Relay a5d6:e3c6:... (193.180.213.68) wants to federate. To accept, add to peers config: url: 193.180.213.68:4433, fingerprint: a5d6:e3c6:..."`
|
||||
- Fingerprints are verified via the TLS certificate (requires the identity fix above)
|
||||
|
||||
### Protocol
|
||||
|
||||
#### Peer Connection
|
||||
|
||||
1. On startup, each relay attempts QUIC connections to all configured peers
|
||||
2. The connection uses SNI `"_federation"` (reserved room name prefix) to distinguish from client connections
|
||||
3. After QUIC handshake, verify the peer's certificate fingerprint matches the configured fingerprint
|
||||
4. If fingerprint mismatch → reject, log warning
|
||||
5. If peer connects but isn't in our config → log the helpful "add to config" message, reject
|
||||
|
||||
#### Room Bridging
|
||||
|
||||
Once two relays are connected:
|
||||
|
||||
1. **Room discovery**: When a local participant joins room "T", the relay sends a `FederationRoomJoin { room: "T" }` signal to all connected peers
|
||||
2. **Room leave**: When the last local participant leaves room "T", send `FederationRoomLeave { room: "T" }`
|
||||
3. **Media forwarding**: For each room that exists on both relays:
|
||||
- Relay A forwards all media packets from its local participants to relay B
|
||||
- Relay B forwards all media packets from its local participants to relay A
|
||||
- Each relay then fans out received federated media to its local participants (same as local SFU forwarding)
|
||||
4. **Participant presence**: `RoomUpdate` signals are merged — local participants + federated participants from all peers
|
||||
|
||||
```
|
||||
Relay A (2 local users) Relay B (1 local user)
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Room "T" │ │ Room "T" │
|
||||
│ Alice (local) ────┼──media──►│ Charlie (local) │
|
||||
│ Bob (local) ────┼──media──►│ │
|
||||
│ │◄──media──┼── Charlie │
|
||||
│ Charlie (federated)│ │ Alice (federated) │
|
||||
│ │ │ Bob (federated) │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
#### Signal Messages (new)
|
||||
|
||||
```rust
|
||||
enum FederationSignal {
|
||||
/// A room exists on this relay with active participants
|
||||
RoomJoin { room: String, participants: Vec<ParticipantInfo> },
|
||||
/// Room is empty on this relay
|
||||
RoomLeave { room: String },
|
||||
/// Participant update for a federated room
|
||||
ParticipantUpdate { room: String, participants: Vec<ParticipantInfo> },
|
||||
}
|
||||
```
|
||||
|
||||
#### Media Forwarding
|
||||
|
||||
Federated media is forwarded as raw QUIC datagrams — the relay doesn't decode/re-encode. Each packet is prefixed with a room identifier so the receiving relay knows which room to fan it out to:
|
||||
|
||||
```
|
||||
[room_hash: 8 bytes][original_media_packet]
|
||||
```
|
||||
|
||||
The 8-byte room hash is computed once when the federation room bridge is established.
|
||||
|
||||
### What Relays DON'T Do
|
||||
|
||||
- **No transcoding** — media passes through as-is. If Alice sends Opus 64k, Charlie receives Opus 64k
|
||||
- **No re-encryption** — packets are already encrypted end-to-end between participants. Relays just forward opaque bytes
|
||||
- **No central coordinator** — each relay independently connects to its configured peers. No master/slave, no consensus protocol
|
||||
- **No automatic peer discovery** — peers must be explicitly configured in YAML
|
||||
|
||||
### Failure Handling
|
||||
|
||||
- If a peer relay goes down, the federation link drops. Local rooms continue to work. Federated participants disappear from presence.
|
||||
- Reconnection: attempt every 30 seconds with exponential backoff up to 5 minutes
|
||||
- If a peer relay restarts with a new identity (bug not fixed), the fingerprint check fails and federation is rejected with a clear error log
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0: Fix Relay Identity (prerequisite)
|
||||
- Derive TLS cert from persisted seed
|
||||
- Same seed → same cert → same fingerprint across restarts
|
||||
|
||||
### Phase 1: YAML Config + Peer Connection
|
||||
- Add `--config relay.yaml` CLI flag
|
||||
- Parse peers config
|
||||
- On startup, connect to all configured peers via QUIC
|
||||
- Verify certificate fingerprints
|
||||
- Log helpful message for unconfigured peers
|
||||
- Reconnect on disconnect
|
||||
|
||||
### Phase 2: Room Bridging
|
||||
- Track which rooms exist on each peer
|
||||
- Forward media for shared rooms
|
||||
- Merge participant presence across peers
|
||||
- Handle room join/leave signals
|
||||
|
||||
### Phase 3: Resilience
|
||||
- Graceful handling of peer disconnect/reconnect
|
||||
- Don't duplicate packets if a participant is reachable via multiple paths
|
||||
- Rate limiting on federation links (prevent amplification)
|
||||
- Metrics: federated rooms, packets forwarded, peer latency
|
||||
|
||||
## Effort Estimates
|
||||
|
||||
| Phase | Scope | Effort |
|
||||
|-------|-------|--------|
|
||||
| 0 | Fix relay TLS identity from seed | 0.5 day |
|
||||
| 1 | YAML config + peer QUIC connections | 2 days |
|
||||
| 2 | Room bridging + media forwarding + presence merge | 3-4 days |
|
||||
| 3 | Resilience + metrics | 2 days |
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Automatic peer discovery (mDNS, DHT, etc.)
|
||||
- Cascading federation (relay A ↔ B ↔ C where A doesn't know C)
|
||||
- Load balancing across relays
|
||||
- Encryption between relays (QUIC provides transport encryption; e2e encryption between participants is orthogonal)
|
||||
- Different rooms on different relays (all federated rooms are bridged by name)
|
||||
41
docs/android/README.md
Normal file
41
docs/android/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# WarzonePhone Android Client
|
||||
|
||||
The WZP Android client is a native VoIP application built with Kotlin/Jetpack Compose on top of a Rust audio engine. It connects to WZP relay servers over QUIC, providing encrypted voice calls with adaptive quality, forward error correction, and acoustic echo cancellation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Build**: `cd android && ./gradlew assembleRelease` (requires NDK 26.1, cargo-ndk)
|
||||
2. **Install**: `adb install app/build/outputs/apk/release/app-release.apk`
|
||||
3. **Run**: Open "WZ Phone", tap **CALL** to connect to the hardcoded relay
|
||||
4. **Relay**: Must be running at the configured address (default `172.16.81.125:4433`)
|
||||
|
||||
## Current State (April 2025)
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| QUIC transport to relay | Working |
|
||||
| Crypto handshake (X25519 + Ed25519) | Working |
|
||||
| Opus 24k encoding/decoding | Working |
|
||||
| Oboe audio I/O (48kHz mono) | Working |
|
||||
| AEC / AGC signal processing | Working |
|
||||
| RaptorQ FEC | Wired (repair symbols not sent yet) |
|
||||
| Jitter buffer | Working |
|
||||
| Adaptive quality switching | Codec-ready, not network-driven yet |
|
||||
| Authentication (featherChat) | Skipped (relay has no --auth-url) |
|
||||
| Media encryption (ChaCha20-Poly1305) | Session derived but not applied to packets |
|
||||
| Foreground service / wake locks | Implemented, not started from UI |
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- [Architecture](architecture.md) - System design, data flow diagrams, thread model
|
||||
- [Build Guide](build-guide.md) - Build environment setup, dependencies, signing
|
||||
- [Debugging](debugging.md) - Crash diagnosis, logcat filters, common issues
|
||||
- [Maintenance](maintenance.md) - Code map, dependency management, upgrade paths
|
||||
- [Roadmap](roadmap.md) - Planned work and known gaps
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Rust native engine**: All audio processing, codecs, FEC, crypto, and networking run in Rust. Kotlin is UI-only.
|
||||
- **Lock-free audio**: SPSC ring buffers with atomic ordering between Oboe C++ callbacks and the Rust codec thread. No mutexes in the audio path.
|
||||
- **cargo-ndk**: The native library (`libwzp_android.so`) is cross-compiled for `arm64-v8a` using cargo-ndk, invoked automatically by Gradle's `cargoNdkBuild` task.
|
||||
- **Single-activity Compose**: One `CallActivity` hosts all UI via Jetpack Compose with `CallViewModel` as the state holder.
|
||||
400
docs/android/architecture.md
Normal file
400
docs/android/architecture.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
The Android client is a four-layer stack: Kotlin UI, JNI bridge, Rust engine, and C++ audio I/O. Each layer communicates through well-defined interfaces with minimal coupling.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Kotlin (Main Thread)"
|
||||
CA[CallActivity]
|
||||
VM[CallViewModel]
|
||||
UI[InCallScreen<br/>Compose UI]
|
||||
CA --> VM
|
||||
VM --> UI
|
||||
end
|
||||
|
||||
subgraph "JNI Bridge"
|
||||
JB[jni_bridge.rs<br/>panic-safe FFI]
|
||||
end
|
||||
|
||||
subgraph "Rust Engine"
|
||||
ENG[WzpEngine<br/>Orchestrator]
|
||||
CT[Codec Thread<br/>20ms real-time loop]
|
||||
NET[Tokio Runtime<br/>2 async workers]
|
||||
PIPE[Pipeline<br/>Encode/Decode/FEC/Jitter]
|
||||
end
|
||||
|
||||
subgraph "C++ Audio"
|
||||
OBOE[Oboe Bridge<br/>Capture + Playout callbacks]
|
||||
RB[Ring Buffers<br/>Lock-free SPSC]
|
||||
end
|
||||
|
||||
subgraph "Network"
|
||||
QUIC[QUIC Connection<br/>quinn]
|
||||
RELAY[WZP Relay<br/>SFU Room]
|
||||
end
|
||||
|
||||
VM <-->|"JNI calls<br/>+ JSON stats"| JB
|
||||
JB <--> ENG
|
||||
ENG --> CT
|
||||
ENG --> NET
|
||||
CT <--> PIPE
|
||||
CT <-->|"Atomic R/W"| RB
|
||||
OBOE <-->|"Atomic R/W"| RB
|
||||
CT <-->|"mpsc channels"| NET
|
||||
NET <-->|"QUIC datagrams<br/>+ streams"| QUIC
|
||||
QUIC <--> RELAY
|
||||
```
|
||||
|
||||
## Thread Model
|
||||
|
||||
The engine uses four distinct thread contexts, each with specific responsibilities and real-time constraints.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Android Main Thread"
|
||||
UI_T["UI + JNI calls<br/>startCall / stopCall / getStats"]
|
||||
end
|
||||
|
||||
subgraph "Oboe Audio Thread (system)"
|
||||
AUD["Capture callback: mic → ring buf<br/>Playout callback: ring buf → speaker<br/>⚡ Highest priority, no allocations"]
|
||||
end
|
||||
|
||||
subgraph "Codec Thread (wzp-codec)"
|
||||
COD["20ms loop:<br/>1. Read capture ring buf<br/>2. AEC → AGC → Encode<br/>3. Send to network channel<br/>4. Recv from network channel<br/>5. FEC → Jitter → Decode<br/>6. Write playout ring buf<br/>⚡ Pinned to big core, RT priority"]
|
||||
end
|
||||
|
||||
subgraph "Tokio Runtime (2 workers)"
|
||||
NET_S["Send task:<br/>Channel → MediaPacket → QUIC datagram"]
|
||||
NET_R["Recv task:<br/>QUIC datagram → MediaPacket → Channel"]
|
||||
HS["Handshake:<br/>CallOffer → CallAnswer"]
|
||||
end
|
||||
|
||||
UI_T -->|"mpsc command channel"| COD
|
||||
COD -->|"tokio::mpsc send_tx"| NET_S
|
||||
NET_R -->|"tokio::mpsc recv_tx"| COD
|
||||
AUD <-->|"Atomic ring buffers"| COD
|
||||
```
|
||||
|
||||
### Thread Priorities and Constraints
|
||||
|
||||
| Thread | Priority | Allocations | Blocking | Lock-free |
|
||||
|--------|----------|-------------|----------|-----------|
|
||||
| Oboe audio | SCHED_FIFO (system) | None | Never | Yes |
|
||||
| Codec | RT priority, big core | Pre-allocated buffers | sleep(remainder of 20ms) | Ring buf: yes, Stats: Mutex |
|
||||
| Tokio workers | Normal | Allowed | Async only | N/A |
|
||||
| Main/JNI | Normal | Allowed | Allowed | N/A |
|
||||
|
||||
## Call Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI as InCallScreen
|
||||
participant VM as CallViewModel
|
||||
participant ENG as WzpEngine (JNI)
|
||||
participant NET as Tokio Network
|
||||
participant RELAY as WZP Relay
|
||||
|
||||
User->>UI: Tap CALL
|
||||
UI->>VM: startCall()
|
||||
VM->>ENG: init() + startCall(relay, room)
|
||||
ENG->>ENG: Create tokio runtime
|
||||
ENG->>NET: Spawn network task
|
||||
|
||||
NET->>RELAY: QUIC connect (SNI = room name)
|
||||
RELAY-->>NET: Connection established
|
||||
|
||||
Note over NET,RELAY: Crypto Handshake
|
||||
NET->>RELAY: CallOffer {identity_pub, ephemeral_pub, signature, profiles}
|
||||
RELAY-->>NET: CallAnswer {ephemeral_pub, chosen_profile, signature}
|
||||
NET->>NET: Derive ChaCha20-Poly1305 session
|
||||
|
||||
ENG->>ENG: Spawn codec thread
|
||||
Note over ENG: State → Active
|
||||
|
||||
loop Every 20ms
|
||||
ENG->>ENG: Read mic → AEC → AGC → Encode
|
||||
ENG->>NET: Encoded frame via channel
|
||||
NET->>RELAY: MediaPacket via QUIC DATAGRAM
|
||||
RELAY->>NET: MediaPacket from other peer
|
||||
NET->>ENG: MediaPacket via channel
|
||||
ENG->>ENG: FEC → Jitter → Decode → Speaker
|
||||
end
|
||||
|
||||
User->>UI: Tap END
|
||||
UI->>VM: stopCall()
|
||||
VM->>ENG: stopCall()
|
||||
ENG->>ENG: Set running=false, send Stop command
|
||||
ENG->>ENG: Join codec thread
|
||||
ENG->>NET: Drop tokio runtime
|
||||
NET->>RELAY: Connection close
|
||||
```
|
||||
|
||||
## Audio Pipeline Detail
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Capture Path"
|
||||
MIC[Microphone] -->|"48kHz i16"| OBOE_C[Oboe Capture<br/>Callback]
|
||||
OBOE_C -->|"ring_write()"| RB_C[Capture<br/>Ring Buffer]
|
||||
RB_C -->|"read_capture()"| AEC[Echo<br/>Canceller]
|
||||
AEC --> AGC[Auto Gain<br/>Control]
|
||||
AGC --> ENC[AdaptiveEncoder<br/>Opus 24k]
|
||||
ENC -->|"Vec u8"| FEC_E[RaptorQ<br/>FEC Encoder]
|
||||
FEC_E -->|"send_tx"| CHAN_S[Send Channel]
|
||||
end
|
||||
|
||||
subgraph "Network"
|
||||
CHAN_S --> PKT_S[MediaPacket<br/>Header + Payload]
|
||||
PKT_S -->|"QUIC DATAGRAM"| RELAY[Relay SFU]
|
||||
RELAY -->|"QUIC DATAGRAM"| PKT_R[MediaPacket<br/>Deserialize]
|
||||
PKT_R -->|"recv_tx"| CHAN_R[Recv Channel]
|
||||
end
|
||||
|
||||
subgraph "Playout Path"
|
||||
CHAN_R --> FEC_D[RaptorQ<br/>FEC Decoder]
|
||||
FEC_D --> JB[Jitter Buffer<br/>10-250 pkts]
|
||||
JB --> DEC[AdaptiveDecoder<br/>Opus 24k]
|
||||
DEC -->|"48kHz i16"| AEC_REF[AEC Far-End<br/>Reference]
|
||||
DEC -->|"write_playout()"| RB_P[Playout<br/>Ring Buffer]
|
||||
RB_P -->|"ring_read()"| OBOE_P[Oboe Playout<br/>Callback]
|
||||
OBOE_P --> SPK[Speaker]
|
||||
end
|
||||
```
|
||||
|
||||
### Audio Parameters
|
||||
|
||||
| Parameter | Value | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Sample rate | 48,000 Hz | Opus native rate |
|
||||
| Channels | 1 (mono) | VoIP only |
|
||||
| Frame size | 960 samples | 20ms at 48kHz |
|
||||
| Ring buffer | 7,680 samples | 160ms (8 frames) |
|
||||
| Bit depth | 16-bit signed int | PCM format |
|
||||
| AEC tail | 100ms | Echo canceller filter length |
|
||||
|
||||
## Crypto Handshake
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Android Client
|
||||
participant Relay as WZP Relay
|
||||
|
||||
Note over Client: Identity seed (32 bytes, random per launch)
|
||||
Note over Client: HKDF → Ed25519 signing key + X25519 static key
|
||||
|
||||
Client->>Client: Generate ephemeral X25519 keypair
|
||||
Client->>Client: Sign(ephemeral_pub || "call-offer") with Ed25519
|
||||
|
||||
Client->>Relay: SignalMessage::CallOffer<br/>{identity_pub, ephemeral_pub, signature, [GOOD, DEGRADED, CATASTROPHIC]}
|
||||
|
||||
Relay->>Relay: Verify Ed25519 signature
|
||||
Relay->>Relay: Generate own ephemeral X25519
|
||||
Relay->>Relay: Sign(ephemeral_pub || "call-answer")
|
||||
Relay->>Relay: DH(relay_ephemeral, client_ephemeral) → shared secret
|
||||
Relay->>Relay: HKDF(shared_secret) → ChaCha20-Poly1305 key
|
||||
|
||||
Relay->>Client: SignalMessage::CallAnswer<br/>{identity_pub, ephemeral_pub, signature, chosen_profile=GOOD}
|
||||
|
||||
Client->>Client: Verify relay signature
|
||||
Client->>Client: DH(client_ephemeral, relay_ephemeral) → same shared secret
|
||||
Client->>Client: HKDF(shared_secret) → same ChaCha20-Poly1305 key
|
||||
|
||||
Note over Client,Relay: Both sides now have identical session key
|
||||
Note over Client,Relay: Media packets can be encrypted (not yet applied)
|
||||
```
|
||||
|
||||
### Key Derivation Chain
|
||||
|
||||
```
|
||||
Identity Seed (32 bytes, random)
|
||||
│
|
||||
├── HKDF(seed, info="warzone-ed25519") → Ed25519 signing key
|
||||
│ └── Public key = identity_pub (32 bytes)
|
||||
│ └── SHA-256(identity_pub)[:16] = fingerprint (16 bytes)
|
||||
│
|
||||
└── HKDF(seed, info="warzone-x25519") → X25519 static key (unused currently)
|
||||
|
||||
Per-Call Ephemeral:
|
||||
Random X25519 keypair → ephemeral_pub (sent in CallOffer)
|
||||
|
||||
Session Key:
|
||||
DH(our_ephemeral_secret, peer_ephemeral_pub) → shared_secret
|
||||
HKDF(shared_secret, info="warzone-session-key") → ChaCha20-Poly1305 key (32 bytes)
|
||||
```
|
||||
|
||||
## QUIC Transport
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "QUIC Connection"
|
||||
EP[Client Endpoint<br/>0.0.0.0:0 UDP]
|
||||
CONN[Connection to Relay<br/>SNI = room name]
|
||||
|
||||
subgraph "Unreliable Channel"
|
||||
DG_S[Send DATAGRAM<br/>MediaPacket serialized]
|
||||
DG_R[Recv DATAGRAM<br/>MediaPacket deserialized]
|
||||
end
|
||||
|
||||
subgraph "Reliable Channel"
|
||||
ST_S[Open bidi stream<br/>JSON length-prefixed<br/>SignalMessage]
|
||||
ST_R[Accept bidi stream<br/>JSON length-prefixed<br/>SignalMessage]
|
||||
end
|
||||
|
||||
EP --> CONN
|
||||
CONN --> DG_S
|
||||
CONN --> DG_R
|
||||
CONN --> ST_S
|
||||
CONN --> ST_R
|
||||
end
|
||||
```
|
||||
|
||||
### QUIC Configuration (VoIP-tuned)
|
||||
|
||||
| Setting | Value | Rationale |
|
||||
|---------|-------|-----------|
|
||||
| ALPN | `wzp` | Protocol identification |
|
||||
| Idle timeout | 30s | Keep connection alive during silence |
|
||||
| Keep-alive | 5s | Prevent NAT timeout |
|
||||
| Datagram receive buffer | 65 KB | Buffer for burst arrivals |
|
||||
| Flow control (recv) | 256 KB | Conservative for VoIP |
|
||||
| Flow control (send) | 128 KB | Prevent bufferbloat |
|
||||
| TLS | Self-signed certs | Development mode |
|
||||
| Certificate verification | Disabled | Client accepts any cert |
|
||||
|
||||
## MediaPacket Wire Format
|
||||
|
||||
```
|
||||
12-byte header:
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Byte 0: V(1) T(1) CodecID(4) Q(1) FecHi(1) │
|
||||
│ Byte 1: FecLo(6) unused(2) │
|
||||
│ Byte 2-3: Sequence number (u16 BE) │
|
||||
│ Byte 4-7: Timestamp ms (u32 BE) │
|
||||
│ Byte 8: FEC block ID │
|
||||
│ Byte 9: FEC symbol index │
|
||||
│ Byte 10: Reserved │
|
||||
│ Byte 11: CSRC count │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Payload: Opus-encoded audio frame │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Optional: QualityReport (4 bytes, if Q=1) │
|
||||
│ loss_pct(u8) rtt_4ms(u8) jitter_ms(u8) │
|
||||
│ bitrate_cap_kbps(u8) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Relay Room Mode (SFU)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Room: android"
|
||||
P1[Phone A<br/>QUIC conn] -->|MediaPacket| RELAY[Relay SFU]
|
||||
RELAY -->|MediaPacket| P2[Phone B<br/>QUIC conn]
|
||||
P2 -->|MediaPacket| RELAY
|
||||
RELAY -->|MediaPacket| P1
|
||||
end
|
||||
|
||||
Note1["Room name from QUIC TLS SNI<br/>No auth required<br/>Packets forwarded to all others"]
|
||||
```
|
||||
|
||||
The relay operates as a Selective Forwarding Unit:
|
||||
1. Client connects via QUIC, room name extracted from TLS SNI
|
||||
2. Crypto handshake completes (relay has its own ephemeral identity)
|
||||
3. Client joins named room
|
||||
4. All received media packets are forwarded to every other participant in the room
|
||||
5. Signaling messages are not forwarded (point-to-point with relay)
|
||||
|
||||
## Adaptive Quality System
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
QR[QualityReport<br/>loss%, RTT, jitter] --> AQC[AdaptiveQualityController]
|
||||
|
||||
AQC -->|"loss<10%, RTT<400ms"| GOOD[GOOD<br/>Opus 24kbps<br/>FEC 20%<br/>20ms frames]
|
||||
AQC -->|"loss 10-40%<br/>RTT 400-600ms"| DEG[DEGRADED<br/>Opus 6kbps<br/>FEC 50%<br/>40ms frames]
|
||||
AQC -->|"loss>40%<br/>RTT>600ms"| CAT[CATASTROPHIC<br/>Codec2 1.2kbps<br/>FEC 100%<br/>40ms frames]
|
||||
|
||||
GOOD -->|"Hysteresis:<br/>sustained degradation"| DEG
|
||||
DEG -->|"Sustained improvement"| GOOD
|
||||
DEG -->|"Further degradation"| CAT
|
||||
CAT -->|"Improvement"| DEG
|
||||
```
|
||||
|
||||
| Profile | Codec | Bitrate | FEC Ratio | Frame Size | FEC Block |
|
||||
|---------|-------|---------|-----------|------------|-----------|
|
||||
| GOOD | Opus 24k | 24 kbps | 20% | 20ms | 5 frames |
|
||||
| DEGRADED | Opus 6k | 6 kbps | 50% | 40ms | 10 frames |
|
||||
| CATASTROPHIC | Codec2 1.2k | 1.2 kbps | 100% | 40ms | 8 frames |
|
||||
|
||||
## Module Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
PROTO[wzp-proto<br/>Types, traits, jitter,<br/>quality, session]
|
||||
CODEC[wzp-codec<br/>Opus, Codec2, AEC,<br/>AGC, resampling]
|
||||
FEC[wzp-fec<br/>RaptorQ fountain codes]
|
||||
CRYPTO[wzp-crypto<br/>Ed25519, X25519,<br/>ChaCha20-Poly1305]
|
||||
TRANSPORT[wzp-transport<br/>QUIC, datagrams,<br/>signaling streams]
|
||||
ANDROID[wzp-android<br/>Engine, JNI bridge,<br/>Oboe audio, pipeline]
|
||||
RELAY[wzp-relay<br/>SFU, rooms, auth,<br/>metrics, probes]
|
||||
|
||||
CODEC --> PROTO
|
||||
FEC --> PROTO
|
||||
CRYPTO --> PROTO
|
||||
TRANSPORT --> PROTO
|
||||
ANDROID --> PROTO
|
||||
ANDROID --> CODEC
|
||||
ANDROID --> FEC
|
||||
ANDROID --> CRYPTO
|
||||
ANDROID --> TRANSPORT
|
||||
RELAY --> PROTO
|
||||
RELAY --> CRYPTO
|
||||
RELAY --> TRANSPORT
|
||||
```
|
||||
|
||||
## File Map
|
||||
|
||||
### Kotlin (`android/app/src/main/java/com/wzp/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `WzpApplication.kt` | App entry, notification channel creation |
|
||||
| `engine/WzpEngine.kt` | JNI wrapper for native engine |
|
||||
| `engine/WzpCallback.kt` | Callback interface for engine events |
|
||||
| `engine/CallStats.kt` | Stats data class with JSON deserialization |
|
||||
| `ui/call/CallActivity.kt` | Activity host, permissions, theme |
|
||||
| `ui/call/CallViewModel.kt` | MVVM state holder, stats polling |
|
||||
| `ui/call/InCallScreen.kt` | Compose UI (idle + in-call states) |
|
||||
| `service/CallService.kt` | Foreground service, wake/wifi locks |
|
||||
| `audio/AudioRouteManager.kt` | Speaker/earpiece/Bluetooth routing |
|
||||
|
||||
### Rust (`crates/wzp-android/src/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib.rs` | Module declarations |
|
||||
| `jni_bridge.rs` | JNI FFI (panic-safe, proper jni crate) |
|
||||
| `engine.rs` | Call orchestrator (threads, channels, lifecycle) |
|
||||
| `pipeline.rs` | Codec pipeline (AEC, AGC, encode, FEC, jitter, decode) |
|
||||
| `audio_android.rs` | Oboe backend, SPSC ring buffers, RT scheduling |
|
||||
| `commands.rs` | Engine command enum |
|
||||
| `stats.rs` | CallState/CallStats types (serde) |
|
||||
|
||||
### C++ (`crates/wzp-android/cpp/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `oboe_bridge.h` | FFI header for Rust-C++ audio interface |
|
||||
| `oboe_bridge.cpp` | Oboe capture/playout callbacks, ring buffer I/O |
|
||||
| `oboe_stub.cpp` | No-op stub for non-Android builds |
|
||||
|
||||
### Build
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `android/app/build.gradle.kts` | Android build config, cargo-ndk task |
|
||||
| `crates/wzp-android/Cargo.toml` | Rust dependencies (cdylib output) |
|
||||
| `crates/wzp-android/build.rs` | C++ compilation, Oboe fetch |
|
||||
155
docs/android/build-guide.md
Normal file
155
docs/android/build-guide.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Build Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| JDK | 17 | Android Gradle builds |
|
||||
| Android SDK | 34 | Compile SDK |
|
||||
| Android NDK | 26.1.10909125 | Native C++/Rust compilation |
|
||||
| Rust | 1.85+ | Native engine (edition 2024) |
|
||||
| cargo-ndk | latest | Cross-compile Rust → Android |
|
||||
| `aarch64-linux-android` target | - | Rust target for ARM64 |
|
||||
|
||||
### Install Rust Android target
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-linux-android
|
||||
cargo install cargo-ndk
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.1.10909125"
|
||||
|
||||
# For manual cargo-ndk builds (Gradle sets these automatically):
|
||||
export CC_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
|
||||
export CXX_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang++"
|
||||
export AR_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||
```
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Full Build (Gradle drives everything)
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
This runs:
|
||||
1. `cargoNdkBuild` task: invokes `cargo ndk -t arm64-v8a -o app/src/main/jniLibs build --release -p wzp-android`
|
||||
2. Compiles Kotlin/Compose code
|
||||
3. Packages APK with signing
|
||||
|
||||
### Native Library Only
|
||||
|
||||
```bash
|
||||
cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --release -p wzp-android
|
||||
```
|
||||
|
||||
Output: `android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so`
|
||||
|
||||
### Skip Native Rebuild
|
||||
|
||||
If the `.so` hasn't changed:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease -x cargoNdkBuild
|
||||
```
|
||||
|
||||
### Debug Build
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Debug APK is ~8.9 MB (unstripped `.so`), release is ~6.9 MB.
|
||||
|
||||
## Signing
|
||||
|
||||
### Debug
|
||||
|
||||
```
|
||||
Keystore: android/keystore/wzp-debug.jks
|
||||
Password: android
|
||||
Key alias: wzp-debug
|
||||
```
|
||||
|
||||
### Release
|
||||
|
||||
```
|
||||
Keystore: android/keystore/wzp-release.jks
|
||||
Password: wzphone2024
|
||||
Key alias: wzp-release
|
||||
```
|
||||
|
||||
Both keystores are checked into the repo for development convenience. For production, replace with proper key management.
|
||||
|
||||
## Build Artifacts
|
||||
|
||||
| Artifact | Path | Size |
|
||||
|----------|------|------|
|
||||
| Debug APK | `android/app/build/outputs/apk/debug/app-debug.apk` | ~8.9 MB |
|
||||
| Release APK | `android/app/build/outputs/apk/release/app-release.apk` | ~6.9 MB |
|
||||
| Native lib | `android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so` | ~5 MB |
|
||||
|
||||
## ABI Support
|
||||
|
||||
Currently only `arm64-v8a` (ARM64) is built. This covers 95%+ of modern Android devices.
|
||||
|
||||
To add more ABIs, edit `build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
|
||||
```
|
||||
|
||||
And update the cargo-ndk command in `cargoNdkBuild` task:
|
||||
|
||||
```kotlin
|
||||
commandLine("cargo", "ndk", "-t", "arm64-v8a", "-t", "armeabi-v7a", ...)
|
||||
```
|
||||
|
||||
## Oboe Dependency
|
||||
|
||||
The Oboe C++ audio library is fetched at build time by `build.rs`:
|
||||
|
||||
1. Attempts `git clone` of Oboe 1.8.1 into `$OUT_DIR/oboe`
|
||||
2. If successful, compiles `oboe_bridge.cpp` with Oboe headers
|
||||
3. If clone fails (no network), falls back to `oboe_stub.cpp` (no-op audio)
|
||||
|
||||
This means **first build requires internet** to fetch Oboe. Subsequent builds use the cached checkout.
|
||||
|
||||
## Common Build Issues
|
||||
|
||||
### `cargo ndk` not found
|
||||
|
||||
```bash
|
||||
cargo install cargo-ndk
|
||||
```
|
||||
|
||||
### Missing Android target
|
||||
|
||||
```bash
|
||||
rustup target add aarch64-linux-android
|
||||
```
|
||||
|
||||
### NDK not found
|
||||
|
||||
Ensure `ANDROID_NDK_HOME` points to the NDK directory containing `toolchains/llvm/`.
|
||||
|
||||
### C++ compilation errors
|
||||
|
||||
Check that `CXX_aarch64_linux_android` points to a valid clang++ from the NDK.
|
||||
|
||||
### Gradle daemon issues
|
||||
|
||||
```bash
|
||||
./gradlew --stop
|
||||
./gradlew assembleRelease --no-daemon
|
||||
```
|
||||
214
docs/android/debugging.md
Normal file
214
docs/android/debugging.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Debugging Guide
|
||||
|
||||
## Crash on Launch
|
||||
|
||||
### Symptom: App crashes immediately after opening
|
||||
|
||||
**Most likely cause: Namespace mismatch in AndroidManifest.xml**
|
||||
|
||||
The Gradle namespace is `com.wzp.phone` but all Kotlin classes are in package `com.wzp.*`. If the manifest uses shorthand names (`.WzpApplication`, `.ui.call.CallActivity`), Android resolves them as `com.wzp.phone.WzpApplication` which doesn't exist.
|
||||
|
||||
**Fix**: Always use fully-qualified class names in the manifest:
|
||||
|
||||
```xml
|
||||
<!-- WRONG -->
|
||||
<application android:name=".WzpApplication">
|
||||
<activity android:name=".ui.call.CallActivity">
|
||||
|
||||
<!-- CORRECT -->
|
||||
<application android:name="com.wzp.WzpApplication">
|
||||
<activity android:name="com.wzp.ui.call.CallActivity">
|
||||
```
|
||||
|
||||
### Symptom: Crash in `System.loadLibrary("wzp_android")`
|
||||
|
||||
The native `.so` is missing or incompatible. Check:
|
||||
|
||||
```bash
|
||||
# Verify the .so exists in the APK
|
||||
unzip -l app-release.apk | grep libwzp
|
||||
# Should show: lib/arm64-v8a/libwzp_android.so
|
||||
|
||||
# Verify ABI matches device
|
||||
adb shell getprop ro.product.cpu.abi
|
||||
# Should return: arm64-v8a
|
||||
```
|
||||
|
||||
### Symptom: Crash when calling `nativeGetStats()` (returns null jstring)
|
||||
|
||||
The JNI bridge must return a valid `jstring`, not a null pointer. The Kotlin side declares the return as `String?` (nullable) and wraps in try/catch:
|
||||
|
||||
```kotlin
|
||||
fun getStats(): String {
|
||||
if (nativeHandle == 0L) return "{}"
|
||||
return try {
|
||||
nativeGetStats(nativeHandle) ?: "{}"
|
||||
} catch (_: Exception) {
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Symptom: Tracing subscriber panic
|
||||
|
||||
`tracing_subscriber::fmt()` writes to stdout, which doesn't exist on Android. The init was removed. If you need logging, use `android_logger` crate instead.
|
||||
|
||||
## Logcat Filters
|
||||
|
||||
### View all WZP logs
|
||||
|
||||
```bash
|
||||
adb logcat -s wzp-android:V wzp-codec:V wzp-net:V
|
||||
```
|
||||
|
||||
### View Rust tracing output (if android_logger is added)
|
||||
|
||||
```bash
|
||||
adb logcat | grep -E "(wzp|WzpEngine|CallActivity)"
|
||||
```
|
||||
|
||||
### View Oboe audio logs
|
||||
|
||||
```bash
|
||||
adb logcat -s AAudio:V oboe:V
|
||||
```
|
||||
|
||||
### View native crashes
|
||||
|
||||
```bash
|
||||
adb logcat -s DEBUG:V libc:V
|
||||
```
|
||||
|
||||
Look for `signal 11 (SIGSEGV)` or `signal 6 (SIGABRT)` with a backtrace in `libwzp_android.so`.
|
||||
|
||||
### Symbolicate native crash
|
||||
|
||||
```bash
|
||||
# Find the .so with debug symbols (before stripping)
|
||||
SO_PATH="target/aarch64-linux-android/release/libwzp_android.so"
|
||||
|
||||
# Use addr2line from NDK
|
||||
$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \
|
||||
-e $SO_PATH -f 0x<address_from_crash>
|
||||
```
|
||||
|
||||
## Network Issues
|
||||
|
||||
### Call stuck on "Connecting..."
|
||||
|
||||
The QUIC handshake to the relay is failing. Common causes:
|
||||
|
||||
1. **Relay not running**: Verify the relay is listening:
|
||||
```bash
|
||||
nc -zvu 172.16.81.125 4433
|
||||
```
|
||||
|
||||
2. **Wrong relay address**: Hardcoded in `CallViewModel.kt`:
|
||||
```kotlin
|
||||
const val DEFAULT_RELAY = "172.16.81.125:4433"
|
||||
```
|
||||
|
||||
3. **QUIC blocked by firewall**: QUIC uses UDP. Many networks block UDP traffic. Ensure UDP port 4433 is open.
|
||||
|
||||
4. **TLS handshake failure**: The client uses `client_config()` which disables certificate verification. If the relay's QUIC config changed, this may fail.
|
||||
|
||||
### Connected but no audio
|
||||
|
||||
1. **Microphone permission denied**: Check Android settings. The app requests `RECORD_AUDIO` on first launch.
|
||||
|
||||
2. **Oboe failed to start**: The codec thread logs this. Check logcat for "failed to start audio".
|
||||
|
||||
3. **Ring buffer underrun**: The stats overlay shows "Under" count. High underruns mean the codec thread isn't keeping up.
|
||||
|
||||
4. **Network not forwarding**: If both phones show "Active" but frame counters aren't increasing, the relay may not be forwarding. Check relay logs.
|
||||
|
||||
### High packet loss
|
||||
|
||||
The stats overlay shows loss percentage. Common causes:
|
||||
|
||||
- Wi-Fi congestion (try cellular or move closer to AP)
|
||||
- UDP throttling by carrier/ISP
|
||||
- Relay overloaded (check relay metrics)
|
||||
|
||||
## Audio Issues
|
||||
|
||||
### Echo
|
||||
|
||||
AEC (Acoustic Echo Cancellation) is enabled by default with a 100ms tail. If echo persists:
|
||||
|
||||
- The AEC may need a longer tail for the specific acoustic environment
|
||||
- Speaker volume too high overwhelms the canceller
|
||||
- Check that `last_decoded_farend` is being set (playout path working)
|
||||
|
||||
### Robot voice / glitching
|
||||
|
||||
Usually caused by jitter buffer underruns. The jitter buffer adapts between 10-250 packets. Check:
|
||||
|
||||
- `jitter_buffer_depth` in stats (should be > 0 during active call)
|
||||
- `underruns` counter (should not climb rapidly)
|
||||
- Network jitter (high jitter_ms causes adaptation)
|
||||
|
||||
### No sound from speaker
|
||||
|
||||
1. Check `isSpeaker` state in the UI
|
||||
2. Oboe playout stream may have failed — check logcat for Oboe errors
|
||||
3. Ring buffer might be empty — check `framesDecoded` counter
|
||||
|
||||
## JNI Issues
|
||||
|
||||
### `UnsatisfiedLinkError: No implementation found for...`
|
||||
|
||||
The JNI function name doesn't match. JNI names must follow the pattern:
|
||||
```
|
||||
Java_com_wzp_engine_WzpEngine_<methodName>
|
||||
```
|
||||
|
||||
If the package structure changes, all JNI function names must be updated in `jni_bridge.rs`.
|
||||
|
||||
### Panic across FFI boundary
|
||||
|
||||
All JNI functions wrap their body in `panic::catch_unwind()`. If a Rust panic escapes to Java, it causes a `SIGABRT`. The catch_unwind returns safe defaults:
|
||||
|
||||
| Function | Panic return |
|
||||
|----------|--------------|
|
||||
| `nativeInit` | 0 (null handle) |
|
||||
| `nativeStartCall` | -1 (error) |
|
||||
| `nativeGetStats` | `JObject::null()` |
|
||||
| Others | void (silently swallowed) |
|
||||
|
||||
### Thread safety
|
||||
|
||||
All JNI methods must be called from the same thread (Android main thread). The `EngineHandle` is a raw pointer — concurrent access is undefined behavior.
|
||||
|
||||
## Stats JSON Format
|
||||
|
||||
The `nativeGetStats()` returns JSON matching this Rust struct:
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "Active",
|
||||
"duration_secs": 42.5,
|
||||
"quality_tier": 0,
|
||||
"loss_pct": 0.5,
|
||||
"rtt_ms": 45,
|
||||
"jitter_ms": 12,
|
||||
"jitter_buffer_depth": 3,
|
||||
"frames_encoded": 2125,
|
||||
"frames_decoded": 2100,
|
||||
"underruns": 5
|
||||
}
|
||||
```
|
||||
|
||||
Kotlin deserializes this via `CallStats.fromJson()` using `org.json.JSONObject` (Android built-in, no library needed).
|
||||
|
||||
## Diagnostic Checklist
|
||||
|
||||
When something doesn't work, check in this order:
|
||||
|
||||
1. **APK installed for correct ABI?** (`arm64-v8a` only)
|
||||
2. **Manifest class names fully qualified?** (no dots prefix)
|
||||
3. **Relay running and reachable?** (`nc -zvu <host> <port>`)
|
||||
4. **Microphone permission granted?**
|
||||
5. **Stats polling working?** (check if frame counters increment)
|
||||
6. **Logcat for native crashes?** (`adb logcat -s DEBUG:V`)
|
||||
7. **Network connectivity?** (UDP port open, no firewall)
|
||||
394
docs/android/fix-audio-ring-desync.md
Normal file
394
docs/android/fix-audio-ring-desync.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Fix: AudioRing SPSC Buffer Cursor Desync
|
||||
|
||||
## Problem
|
||||
|
||||
A critical bug causes 10-16 seconds of bidirectional audio silence mid-call (~25-30s in). Both participants go silent at the exact same moment. The QUIC transport, relay, Opus codec, and FEC are all healthy — the bug is in the lock-free ring buffer that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
|
||||
|
||||
**Root cause:** `AudioRing::write()` modifies `read_pos` from the producer thread during overflow handling (lines 68-72 of `audio_ring.rs`). This violates the SPSC invariant — only the consumer should own `read_pos`. When both threads write to `read_pos`, a race corrupts the cursor state, causing the reader to see an empty or stale buffer for 12-16 seconds.
|
||||
|
||||
**Full forensics:** `debug/INCIDENT-2026-04-06-playout-ring-desync.md`
|
||||
|
||||
---
|
||||
|
||||
## Solution: Reader-Detects-Lap Architecture
|
||||
|
||||
The writer NEVER touches `read_pos`. On overflow, the writer simply overwrites old buffer data and advances `write_pos`. The reader detects it was lapped and self-corrects by snapping its own `read_pos` forward.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Rewrite `AudioRing`
|
||||
|
||||
**File:** `crates/wzp-android/src/audio_ring.rs`
|
||||
|
||||
Replace the entire implementation with:
|
||||
|
||||
**Constants:**
|
||||
```rust
|
||||
/// Ring buffer capacity — must be a power of 2 for bitmask indexing.
|
||||
/// 16384 samples = 341.3ms at 48kHz mono. Provides 70% more headroom
|
||||
/// than the previous 9600 (200ms) for surviving Android GC pauses.
|
||||
const RING_CAPACITY: usize = 16384; // 2^14
|
||||
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
```
|
||||
|
||||
**Struct:**
|
||||
```rust
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
write_pos: AtomicUsize, // monotonically increasing, ONLY written by producer
|
||||
read_pos: AtomicUsize, // monotonically increasing, ONLY written by consumer
|
||||
overflow_count: AtomicU64, // incremented by reader when it detects a lap
|
||||
underrun_count: AtomicU64, // incremented by reader when ring is empty
|
||||
}
|
||||
```
|
||||
|
||||
**`write()` — producer. Does NOT touch `read_pos`:**
|
||||
```rust
|
||||
pub fn write(&self, samples: &[i16]) -> usize {
|
||||
let count = samples.len().min(RING_CAPACITY);
|
||||
let w = self.write_pos.load(Ordering::Relaxed);
|
||||
|
||||
for i in 0..count {
|
||||
unsafe {
|
||||
let ptr = self.buf.as_ptr() as *mut i16;
|
||||
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||
}
|
||||
}
|
||||
|
||||
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
```
|
||||
|
||||
**`read()` — consumer. Detects lap, self-corrects:**
|
||||
```rust
|
||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||
|
||||
let mut avail = w.wrapping_sub(r);
|
||||
|
||||
// Lap detection: writer has overwritten our unread data.
|
||||
// Snap read_pos forward to oldest valid data in the buffer.
|
||||
// Safe because we (the reader) are the sole owner of read_pos.
|
||||
if avail > RING_CAPACITY {
|
||||
r = w.wrapping_sub(RING_CAPACITY);
|
||||
avail = RING_CAPACITY;
|
||||
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let count = out.len().min(avail);
|
||||
if count == 0 {
|
||||
if w == r {
|
||||
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
for i in 0..count {
|
||||
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||
}
|
||||
|
||||
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
|
||||
count
|
||||
}
|
||||
```
|
||||
|
||||
**`available()` — clamped for external callers:**
|
||||
```rust
|
||||
pub fn available(&self) -> usize {
|
||||
let w = self.write_pos.load(Ordering::Acquire);
|
||||
let r = self.read_pos.load(Ordering::Relaxed);
|
||||
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||
}
|
||||
```
|
||||
|
||||
**`free_space()` — keep for API compat:**
|
||||
```rust
|
||||
pub fn free_space(&self) -> usize {
|
||||
RING_CAPACITY.saturating_sub(self.available())
|
||||
}
|
||||
```
|
||||
|
||||
**Diagnostic accessors:**
|
||||
```rust
|
||||
pub fn overflow_count(&self) -> u64 {
|
||||
self.overflow_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn underrun_count(&self) -> u64 {
|
||||
self.underrun_count.load(Ordering::Relaxed)
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor:**
|
||||
```rust
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: Box::new([0i16; RING_CAPACITY]),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
underrun_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Imports to add:** `use std::sync::atomic::AtomicU64;`
|
||||
|
||||
**Safety comment update:**
|
||||
```rust
|
||||
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Add counter fields to `CallStats`
|
||||
|
||||
**File:** `crates/wzp-android/src/stats.rs`
|
||||
|
||||
Add three fields to the `CallStats` struct (after `fec_recovered`):
|
||||
|
||||
```rust
|
||||
/// Playout ring overflow count (reader was lapped by writer).
|
||||
pub playout_overflows: u64,
|
||||
/// Playout ring underrun count (reader found empty buffer).
|
||||
pub playout_underruns: u64,
|
||||
/// Capture ring overflow count.
|
||||
pub capture_overflows: u64,
|
||||
```
|
||||
|
||||
These derive `Default` (= 0) automatically via the existing `#[derive(Default)]`.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Wire ring diagnostics into engine stats + logging
|
||||
|
||||
**File:** `crates/wzp-android/src/engine.rs`
|
||||
|
||||
**3a.** In `get_stats()` (~line 181), populate the new fields:
|
||||
|
||||
```rust
|
||||
stats.playout_overflows = self.state.playout_ring.overflow_count();
|
||||
stats.playout_underruns = self.state.playout_ring.underrun_count();
|
||||
stats.capture_overflows = self.state.capture_ring.overflow_count();
|
||||
```
|
||||
|
||||
**3b.** In the recv task periodic stats log, add ring health:
|
||||
|
||||
```rust
|
||||
info!(
|
||||
frames_decoded,
|
||||
fec_recovered,
|
||||
recv_errors,
|
||||
max_recv_gap_ms,
|
||||
playout_avail = state.playout_ring.available(),
|
||||
playout_overflows = state.playout_ring.overflow_count(),
|
||||
playout_underruns = state.playout_ring.underrun_count(),
|
||||
"recv stats"
|
||||
);
|
||||
```
|
||||
|
||||
**3c.** In the send task periodic stats log, add capture ring health:
|
||||
|
||||
```rust
|
||||
info!(
|
||||
seq = s,
|
||||
block_id,
|
||||
frames_sent,
|
||||
frames_dropped,
|
||||
send_errors,
|
||||
ring_avail = state.capture_ring.available(),
|
||||
capture_overflows = state.capture_ring.overflow_count(),
|
||||
"send stats"
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Parse new stats in Kotlin
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/engine/CallStats.kt`
|
||||
|
||||
Add fields to the data class:
|
||||
|
||||
```kotlin
|
||||
val playoutOverflows: Long = 0,
|
||||
val playoutUnderruns: Long = 0,
|
||||
val captureOverflows: Long = 0,
|
||||
```
|
||||
|
||||
Add parsing in `fromJson()`:
|
||||
|
||||
```kotlin
|
||||
playoutOverflows = obj.optLong("playout_overflows", 0),
|
||||
playoutUnderruns = obj.optLong("playout_underruns", 0),
|
||||
captureOverflows = obj.optLong("capture_overflows", 0),
|
||||
```
|
||||
|
||||
No UI changes needed — these fields will appear in debug report JSON automatically.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Unit tests
|
||||
|
||||
**File:** `crates/wzp-android/src/audio_ring.rs` — add `#[cfg(test)] mod tests`
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capacity_is_power_of_two() {
|
||||
assert!(RING_CAPACITY.is_power_of_two());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_write_read() {
|
||||
let ring = AudioRing::new();
|
||||
let input: Vec<i16> = (0..960).map(|i| i as i16).collect();
|
||||
ring.write(&input);
|
||||
assert_eq!(ring.available(), 960);
|
||||
|
||||
let mut output = vec![0i16; 960];
|
||||
let read = ring.read(&mut output);
|
||||
assert_eq!(read, 960);
|
||||
assert_eq!(output, input);
|
||||
assert_eq!(ring.available(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraparound() {
|
||||
let ring = AudioRing::new();
|
||||
let frame = vec![42i16; 960];
|
||||
// Write enough to wrap the buffer multiple times
|
||||
for _ in 0..20 {
|
||||
ring.write(&frame);
|
||||
let mut out = vec![0i16; 960];
|
||||
ring.read(&mut out);
|
||||
assert!(out.iter().all(|&s| s == 42));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_detected_by_reader() {
|
||||
let ring = AudioRing::new();
|
||||
// Write more than RING_CAPACITY without reading
|
||||
let big = vec![7i16; RING_CAPACITY + 960];
|
||||
ring.write(&big[..RING_CAPACITY]);
|
||||
ring.write(&big[RING_CAPACITY..]);
|
||||
|
||||
// Reader should detect lap
|
||||
let mut out = vec![0i16; 960];
|
||||
let read = ring.read(&mut out);
|
||||
assert!(read > 0);
|
||||
assert_eq!(ring.overflow_count(), 1);
|
||||
// Data should be from the most recent writes
|
||||
assert!(out.iter().all(|&s| s == 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_never_modifies_read_pos() {
|
||||
let ring = AudioRing::new();
|
||||
// Read pos should stay at 0 until read() is called
|
||||
let data = vec![1i16; RING_CAPACITY + 960];
|
||||
ring.write(&data);
|
||||
// read_pos is private, but we can check available() > CAPACITY
|
||||
// which proves write() didn't advance read_pos
|
||||
let w = ring.write_pos.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let r = ring.read_pos.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(r, 0, "write() must not modify read_pos");
|
||||
assert!(w.wrapping_sub(r) > RING_CAPACITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underrun_counted() {
|
||||
let ring = AudioRing::new();
|
||||
let mut out = vec![0i16; 960];
|
||||
let read = ring.read(&mut out);
|
||||
assert_eq!(read, 0);
|
||||
assert_eq!(ring.underrun_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_recovery_reads_recent_data() {
|
||||
let ring = AudioRing::new();
|
||||
// Fill with old data
|
||||
let old = vec![1i16; RING_CAPACITY];
|
||||
ring.write(&old);
|
||||
// Overwrite with new data (lapping the reader)
|
||||
let new_data = vec![99i16; 960];
|
||||
ring.write(&new_data);
|
||||
|
||||
// Reader should snap forward and get recent data
|
||||
let mut out = vec![0i16; RING_CAPACITY];
|
||||
let read = ring.read(&mut out);
|
||||
assert_eq!(read, RING_CAPACITY);
|
||||
// The last 960 samples should be 99
|
||||
assert!(out[RING_CAPACITY - 960..].iter().all(|&s| s == 99));
|
||||
assert_eq!(ring.overflow_count(), 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Ordering Reference
|
||||
|
||||
| Operation | Ordering | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| `write_pos.store` in `write()` | Release | Buffer writes visible before cursor advances |
|
||||
| `write_pos.load` in `read()` | Acquire | Pairs with Release above — sees all buffer writes |
|
||||
| `write_pos.load` in `write()` | Relaxed | Writer is sole owner of write_pos |
|
||||
| `read_pos.load` in `read()` | Relaxed | Reader is sole owner of read_pos |
|
||||
| `read_pos.store` in `read()` | Release | Makes available() consistent from any thread |
|
||||
| `read_pos.load` in `available()` | Relaxed | Informational only, slight staleness OK |
|
||||
| All counters | Relaxed | Diagnostic only |
|
||||
|
||||
---
|
||||
|
||||
## Capacity Tradeoff
|
||||
|
||||
| Capacity | Duration | Memory | Verdict |
|
||||
|----------|----------|--------|---------|
|
||||
| 8192 (2^13) | 170ms | 16KB | Less than current 200ms — risky |
|
||||
| **16384 (2^14)** | **341ms** | **32KB** | **70% more headroom, bitmask indexing** |
|
||||
| 32768 (2^15) | 682ms | 64KB | Excessive latency on overflow recovery |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo test -p wzp-android` — new unit tests pass
|
||||
2. `cargo ndk -t arm64-v8a build --release -p wzp-android` — ARM cross-compile succeeds
|
||||
3. Build APK, install on both test devices (Nothing A059 + Pixel 6)
|
||||
4. 2+ minute call — verify no audio gaps
|
||||
5. Check debug report JSON: `playout_overflows` should be 0 or very small
|
||||
6. Check logcat `wzp_android` tag: send/recv stats show healthy ring state
|
||||
7. Stress test: play music through one device speaker while on call — forces high ring throughput
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `crates/wzp-android/src/audio_ring.rs` | Complete rewrite — the core fix |
|
||||
| `crates/wzp-android/src/stats.rs` | Add 3 counter fields |
|
||||
| `crates/wzp-android/src/engine.rs` | Wire counters into get_stats() + periodic logs |
|
||||
| `android/app/src/main/java/com/wzp/engine/CallStats.kt` | Parse 3 new JSON fields |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `AudioPipeline.kt` — calls `readAudio()`/`writeAudio()` unchanged; ring fix is transparent
|
||||
- `jni_bridge.rs` — JNI bridge passes through unchanged
|
||||
- `audio_android.rs` — separate Oboe-based ring, currently unused, different design
|
||||
- Relay code — relay is confirmed healthy
|
||||
- Desktop client — uses `Mutex + mpsc`, not `AudioRing`
|
||||
149
docs/android/fix-capture-thread-crash.md
Normal file
149
docs/android/fix-capture-thread-crash.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Fix: Capture/Playout Thread Use-After-Free on Hangup
|
||||
|
||||
## Problem
|
||||
|
||||
App crashes (SIGSEGV) when hanging up a call. The capture thread (`wzp-capture`) calls `engine.writeAudio()` via JNI after `teardown()` has freed the native engine handle. Same race exists for the playout thread's `readAudio()`.
|
||||
|
||||
**Root cause:** TOCTOU race between the `nativeHandle == 0L` check in `WzpEngine.writeAudio()`/`readAudio()` and `destroy()` freeing the native memory on the ViewModel thread. Audio threads can't be joined (libcrypto TLS destructor crash), so there's no synchronization between `stopAudio()` and `destroy()`.
|
||||
|
||||
**Full forensics:** `debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md`
|
||||
|
||||
---
|
||||
|
||||
## Solution: Destroy Latch
|
||||
|
||||
Add a `CountDownLatch(2)` that both audio threads count down after exiting their loops. `teardown()` awaits the latch (with timeout) before calling `destroy()`, guaranteeing no in-flight JNI calls.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add a drain latch to `AudioPipeline`
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/audio/AudioPipeline.kt`
|
||||
|
||||
Add a `CountDownLatch` field:
|
||||
|
||||
```kotlin
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AudioPipeline(private val context: Context) {
|
||||
// ... existing fields ...
|
||||
|
||||
/** Latch counted down by each audio thread after exiting its loop.
|
||||
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
|
||||
private var drainLatch: CountDownLatch? = null
|
||||
```
|
||||
|
||||
In `start()`, create the latch before spawning threads:
|
||||
|
||||
```kotlin
|
||||
fun start(engine: WzpEngine) {
|
||||
if (running) return
|
||||
running = true
|
||||
drainLatch = CountDownLatch(2) // one for capture, one for playout
|
||||
|
||||
captureThread = Thread({
|
||||
runCapture(engine)
|
||||
drainLatch?.countDown() // signal: capture loop exited
|
||||
parkThread()
|
||||
}, "wzp-capture").apply { ... }
|
||||
|
||||
playoutThread = Thread({
|
||||
runPlayout(engine)
|
||||
drainLatch?.countDown() // signal: playout loop exited
|
||||
parkThread()
|
||||
}, "wzp-playout").apply { ... }
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Add `awaitDrain()` — called by ViewModel before `destroy()`:
|
||||
|
||||
```kotlin
|
||||
/** Block until both audio threads have exited their loops (max 200ms).
|
||||
* After this returns, no more JNI calls to the engine will be made. */
|
||||
fun awaitDrain(): Boolean {
|
||||
return drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
|
||||
}
|
||||
```
|
||||
|
||||
`stop()` remains unchanged (non-blocking, sets `running = false`).
|
||||
|
||||
### Step 2: Update `CallViewModel.teardown()` to await drain
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
|
||||
|
||||
Change teardown to wait for audio threads before destroying:
|
||||
|
||||
```kotlin
|
||||
private fun teardown(stopService: Boolean = true) {
|
||||
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
|
||||
val hadCall = audioStarted
|
||||
CallService.onStopFromNotification = null
|
||||
stopAudio() // sets running=false (non-blocking)
|
||||
stopStatsPolling()
|
||||
|
||||
// Wait for audio threads to exit their loops before destroying the engine.
|
||||
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
|
||||
val drained = audioPipeline?.awaitDrain() ?: true
|
||||
if (!drained) {
|
||||
Log.w(TAG, "teardown: audio threads did not drain in time")
|
||||
}
|
||||
audioPipeline = null
|
||||
|
||||
Log.i(TAG, "teardown: stopping engine")
|
||||
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
|
||||
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
|
||||
engine = null
|
||||
engineInitialized = false
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
**Key change:** `awaitDrain()` is called AFTER `stopAudio()` (which sets `running=false`) but BEFORE `engine?.destroy()`. The latch guarantees both threads have exited their `while(running)` loops and will never call `writeAudio`/`readAudio` again.
|
||||
|
||||
Also move `audioPipeline = null` to after `awaitDrain()` to keep the reference alive for the latch call.
|
||||
|
||||
### Step 3: Move `stopAudio()` pipeline nulling
|
||||
|
||||
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
|
||||
|
||||
In `stopAudio()`, do NOT null out the pipeline — let `teardown()` handle it after drain:
|
||||
|
||||
```kotlin
|
||||
private fun stopAudio() {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop() // sets running=false
|
||||
// DON'T null audioPipeline here — teardown() needs it for awaitDrain()
|
||||
audioRouteManager?.unregister()
|
||||
audioRouteManager?.setSpeaker(false)
|
||||
_isSpeaker.value = false
|
||||
audioStarted = false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `android/.../audio/AudioPipeline.kt` | Add `CountDownLatch`, `countDown()` in threads, `awaitDrain()` method |
|
||||
| `android/.../ui/call/CallViewModel.kt` | `teardown()` calls `awaitDrain()` before `destroy()`; `stopAudio()` doesn't null pipeline |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- `WzpEngine.kt` — the `nativeHandle == 0L` guard stays as defense-in-depth
|
||||
- `jni_bridge.rs` — `panic::catch_unwind` stays as last resort
|
||||
- `AudioPipeline.stop()` — remains non-blocking
|
||||
- Thread parking — still needed to avoid libcrypto TLS crash
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build APK, install on test device
|
||||
2. Make a call, hang up — verify no crash in logcat (`adb logcat -s AndroidRuntime:E DEBUG:F`)
|
||||
3. Rapid call/hangup/call/hangup cycles — stress the teardown path
|
||||
4. Check logcat for `teardown: audio threads did not drain in time` — should never appear under normal conditions
|
||||
5. Verify debug report still works after hangup (latch doesn't interfere with report collection)
|
||||
190
docs/android/maintenance.md
Normal file
190
docs/android/maintenance.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Maintenance Guide
|
||||
|
||||
## Code Map — Where to Change Things
|
||||
|
||||
### Changing the relay address or room
|
||||
|
||||
Edit `CallViewModel.kt`:
|
||||
```kotlin
|
||||
companion object {
|
||||
const val DEFAULT_RELAY = "172.16.81.125:4433"
|
||||
const val DEFAULT_ROOM = "android"
|
||||
}
|
||||
```
|
||||
|
||||
For a proper settings screen, add a new Composable in `ui/` that persists to `SharedPreferences` and passes values to `viewModel.startCall(relay, room)`.
|
||||
|
||||
### Adding authentication
|
||||
|
||||
1. In `CallViewModel.startCall()`, pass a token parameter
|
||||
2. In `engine.rs`, after QUIC connect but before CallOffer, send:
|
||||
```rust
|
||||
transport.send_signal(&SignalMessage::AuthToken { token: auth_token }).await?;
|
||||
```
|
||||
3. Wait for the relay to accept before proceeding to handshake
|
||||
4. Start relay with `--auth-url <featherchat-endpoint>`
|
||||
|
||||
### Enabling media encryption
|
||||
|
||||
The crypto session is already derived in `engine.rs` but not applied to packets. To enable:
|
||||
|
||||
1. Pass `_session` (currently unused) to the send/recv tasks
|
||||
2. Before `transport.send_media()`, encrypt the payload:
|
||||
```rust
|
||||
let mut ciphertext = Vec::new();
|
||||
session.encrypt(&header_bytes, &payload, &mut ciphertext)?;
|
||||
packet.payload = Bytes::from(ciphertext);
|
||||
```
|
||||
3. After `transport.recv_media()`, decrypt:
|
||||
```rust
|
||||
let mut plaintext = Vec::new();
|
||||
session.decrypt(&header_bytes, &pkt.payload, &mut plaintext)?;
|
||||
pkt.payload = Bytes::from(plaintext);
|
||||
```
|
||||
|
||||
### Adding a new codec / quality profile
|
||||
|
||||
1. Define the profile in `wzp-proto/src/codec_id.rs`
|
||||
2. Implement `AudioEncoder`/`AudioDecoder` traits in `wzp-codec`
|
||||
3. Register in `AdaptiveEncoder`/`AdaptiveDecoder` switch logic
|
||||
4. Add to `supported_profiles` in the CallOffer (engine.rs)
|
||||
|
||||
### Changing audio parameters
|
||||
|
||||
- **Sample rate**: Change `FRAME_SAMPLES` in `audio_android.rs` and `WzpOboeConfig.sample_rate` in `oboe_bridge.cpp`. Must match the codec's expected rate.
|
||||
- **Frame duration**: Change `FRAME_SAMPLES` (960 = 20ms at 48kHz, 1920 = 40ms)
|
||||
- **Ring buffer size**: Change `RING_CAPACITY` in `audio_android.rs`
|
||||
- **AEC tail length**: Change the `100` in `Pipeline::new()` → `EchoCanceller::new(48000, 100)`
|
||||
|
||||
### Adding x86_64 support (emulator)
|
||||
|
||||
1. `build.gradle.kts`: add `"x86_64"` to `abiFilters`
|
||||
2. `cargoNdkBuild` task: add `-t x86_64`
|
||||
3. `build.rs`: handle `x86_64-linux-android` target for Oboe
|
||||
4. Note: Oboe in the emulator uses a different audio HAL — audio quality will differ
|
||||
|
||||
## Dependency Overview
|
||||
|
||||
### Rust Crate Dependencies (wzp-android)
|
||||
|
||||
| Crate | Version | Purpose | Upgrade risk |
|
||||
|-------|---------|---------|--------------|
|
||||
| `jni` | 0.21 | Java FFI | Low — stable API |
|
||||
| `tokio` | 1.x | Async runtime | Low |
|
||||
| `quinn` | 0.11 | QUIC transport | Medium — breaking changes between 0.x |
|
||||
| `rustls` | 0.23 | TLS for QUIC | Medium — tied to quinn version |
|
||||
| `serde_json` | 1.x | Stats serialization | Low |
|
||||
| `anyhow` | 1.x | Error handling | Low |
|
||||
| `tracing` | 0.1 | Logging | Low |
|
||||
| `rand` | 0.8 | Random seed generation | Low |
|
||||
|
||||
### Workspace Crate Dependencies
|
||||
|
||||
| Crate | Purpose | Key trait |
|
||||
|-------|---------|-----------|
|
||||
| `wzp-proto` | Shared types and traits | `MediaTransport`, `AudioEncoder`, `KeyExchange` |
|
||||
| `wzp-codec` | Opus + Codec2 + signal processing | `AdaptiveEncoder`, `EchoCanceller` |
|
||||
| `wzp-fec` | RaptorQ FEC | `RaptorQFecEncoder` |
|
||||
| `wzp-crypto` | Key exchange + encryption | `WarzoneKeyExchange`, `ChaChaSession` |
|
||||
| `wzp-transport` | QUIC connection management | `QuinnTransport`, `connect()` |
|
||||
|
||||
### Android/Kotlin Dependencies
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `compose-bom` | 2024.01.00 | Compose version alignment |
|
||||
| `material3` | (from BOM) | UI components |
|
||||
| `activity-compose` | 1.8.2 | Activity integration |
|
||||
| `lifecycle-runtime-ktx` | 2.7.0 | ViewModel + coroutines |
|
||||
| `core-ktx` | 1.12.0 | Kotlin extensions |
|
||||
|
||||
## Updating Dependencies
|
||||
|
||||
### Rust
|
||||
|
||||
```bash
|
||||
cargo update -p wzp-android
|
||||
cargo ndk -t arm64-v8a build --release -p wzp-android
|
||||
```
|
||||
|
||||
Watch for `quinn`/`rustls` version coupling. They must be compatible:
|
||||
- quinn 0.11 requires rustls 0.23
|
||||
|
||||
### Android/Kotlin
|
||||
|
||||
Update versions in `android/app/build.gradle.kts`. Key compatibility:
|
||||
- `kotlinCompilerExtensionVersion` must match the Kotlin version
|
||||
- `compose-bom` version determines all Compose library versions
|
||||
- `compileSdk` and `targetSdk` should stay in sync
|
||||
|
||||
### NDK
|
||||
|
||||
If upgrading the NDK:
|
||||
1. Update `ndkVersion` in `build.gradle.kts`
|
||||
2. Update `ANDROID_NDK_HOME` environment variable
|
||||
3. Update `CC_aarch64_linux_android` and friends
|
||||
4. Verify Oboe still builds with the new toolchain
|
||||
|
||||
## Key Invariants to Preserve
|
||||
|
||||
1. **JNI function names must match package structure**: If the Kotlin package changes, all `Java_com_wzp_engine_WzpEngine_*` functions in `jni_bridge.rs` must be renamed.
|
||||
|
||||
2. **Manifest uses fully-qualified class names**: Never use `.ClassName` shorthand because the Gradle namespace (`com.wzp.phone`) differs from the Kotlin package (`com.wzp`).
|
||||
|
||||
3. **Stats JSON field names are snake_case**: Rust serializes with serde defaults (snake_case). Kotlin's `CallStats.fromJson()` expects `duration_secs`, `loss_pct`, etc.
|
||||
|
||||
4. **Ring buffer ordering**: Producer uses Release store on write index, consumer uses Acquire load. Breaking this causes torn reads.
|
||||
|
||||
5. **Codec thread owns Pipeline**: Pipeline is `!Send` (Opus encoder state). It must never be accessed from another thread.
|
||||
|
||||
6. **panic::catch_unwind on all JNI functions**: Rust panics unwinding across the FFI boundary is UB. Every JNI-exposed function must catch panics.
|
||||
|
||||
7. **Channel capacity (64)**: Both `send_tx` and `recv_tx` are bounded at 64 packets. If the network is slow, packets are dropped (`try_send` best-effort).
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests (Rust)
|
||||
|
||||
```bash
|
||||
# Run all workspace tests (host, not Android)
|
||||
cargo test
|
||||
|
||||
# Run only wzp-android tests (uses oboe_stub.cpp on host)
|
||||
cargo test -p wzp-android
|
||||
```
|
||||
|
||||
Note: Pipeline, codec, FEC, crypto tests run on the host. Audio tests use stubs.
|
||||
|
||||
### On-Device Testing
|
||||
|
||||
1. Build and install debug APK
|
||||
2. Open app, tap CALL
|
||||
3. Verify in logcat:
|
||||
- `WzpEngine created via JNI`
|
||||
- `connecting to relay...`
|
||||
- `QUIC connected to relay`
|
||||
- `CallOffer sent`
|
||||
- `handshake complete, call active`
|
||||
- `codec thread started`
|
||||
4. Check stats overlay: frame counters should increment
|
||||
5. Speak into mic — other connected device should hear audio
|
||||
|
||||
### Stress Testing
|
||||
|
||||
- Run a call for 30+ minutes — check for memory leaks (stats should be stable)
|
||||
- Kill and restart the relay — client should eventually get a connection error
|
||||
- Toggle mute rapidly — verify no crashes
|
||||
- Switch speaker on/off — verify audio route changes
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
Key metrics to watch during a call:
|
||||
|
||||
| Metric | Healthy Range | Warning | Critical |
|
||||
|--------|--------------|---------|----------|
|
||||
| frames_encoded | Increasing ~50/sec | Stalled | 0 |
|
||||
| frames_decoded | Increasing ~50/sec | Stalled | 0 |
|
||||
| underruns | < 5/min | > 20/min | > 100/min |
|
||||
| jitter_buffer_depth | 2-5 | 0 or >10 | N/A |
|
||||
| loss_pct | < 5% | 5-20% | > 20% |
|
||||
| rtt_ms | < 100ms | 100-300ms | > 500ms |
|
||||
112
docs/android/roadmap.md
Normal file
112
docs/android/roadmap.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Roadmap & Known Gaps
|
||||
|
||||
## Current State Summary
|
||||
|
||||
The Android client can connect to a WZP relay, complete the crypto handshake, and exchange audio in real-time. Two phones on the same network can talk to each other through the relay.
|
||||
|
||||
## What Works (April 2025)
|
||||
|
||||
- QUIC transport to relay with room-based SFU
|
||||
- Full crypto handshake (X25519 ephemeral + Ed25519 signatures)
|
||||
- Opus 24kbps encoding/decoding at 48kHz
|
||||
- Lock-free audio I/O via Oboe (capture + playout)
|
||||
- AEC (acoustic echo cancellation) with 100ms tail
|
||||
- AGC (automatic gain control)
|
||||
- RaptorQ FEC encoder/decoder (wired to pipeline)
|
||||
- Adaptive jitter buffer (10-250 packets)
|
||||
- UI with connect/disconnect, mute, speaker, live stats
|
||||
- Random identity seed per app launch
|
||||
|
||||
## Known Gaps
|
||||
|
||||
### P0 — Must fix for usable calls
|
||||
|
||||
| Gap | Impact | Where to fix |
|
||||
|-----|--------|--------------|
|
||||
| **Media encryption not applied** | Audio sent in cleartext over QUIC | `engine.rs` — pass `_session` to send/recv, encrypt/decrypt payloads |
|
||||
| **FEC repair symbols not sent** | No loss recovery — audio gaps on packet loss | `engine.rs` send task — call `fec_encoder.generate_repair()` and send repair packets |
|
||||
| **Quality reports not sent** | Relay can't monitor quality, no adaptive switching | `engine.rs` — periodically attach `QualityReport` to MediaPacket header |
|
||||
| **CallService not started** | Call dies when app is backgrounded | `CallViewModel.startCall()` — call `CallService.start(context)` |
|
||||
|
||||
### P1 — Important for production
|
||||
|
||||
| Gap | Impact | Where to fix |
|
||||
|-----|--------|--------------|
|
||||
| **Hardcoded relay address** | Can't change server without rebuild | Add settings screen with `SharedPreferences` |
|
||||
| **No reconnection logic** | Connection drop = call over | `engine.rs` network task — detect disconnect, retry with backoff |
|
||||
| **No adaptive quality switching** | Stays on GOOD profile even in bad conditions | Wire `AdaptiveQualityController` to network path quality from `QuinnTransport` |
|
||||
| **Identity seed not persisted** | New identity every launch | Save seed to Android Keystore or SharedPreferences |
|
||||
| **No Bluetooth audio routing** | `AudioRouteManager` exists but not wired to UI | Add Bluetooth button to InCallScreen, call `AudioRouteManager` methods |
|
||||
| **No ringtone/notification for incoming** | Only outgoing calls supported | Need signaling for call setup (currently both sides initiate independently) |
|
||||
|
||||
### P2 — Nice to have
|
||||
|
||||
| Gap | Impact | Where to fix |
|
||||
|-----|--------|--------------|
|
||||
| **No android_logger** | Rust tracing output lost on Android | Add `android_logger` crate, init in `nativeInit()` |
|
||||
| **Stats don't include network metrics** | Loss/RTT/jitter always 0 | Feed `QuinnTransport.path_quality()` back to stats |
|
||||
| **No ProGuard/R8 minification** | Release APK larger than necessary | Enable `isMinifyEnabled = true` in build.gradle.kts |
|
||||
| **Single ABI (arm64-v8a)** | No support for older 32-bit devices or emulators | Add `armeabi-v7a` and `x86_64` to cargo-ndk build |
|
||||
| **No call history** | Can't see past calls | Add Room database for call log |
|
||||
| **No contact integration** | Manual relay/room entry | Add contacts with fingerprint-based identity |
|
||||
|
||||
## Architecture Evolution Plan
|
||||
|
||||
### Phase 1: Make Calls Reliable (current → next)
|
||||
|
||||
```
|
||||
[x] QUIC connection to relay
|
||||
[x] Crypto handshake
|
||||
[x] Audio encode/decode pipeline
|
||||
[ ] Media encryption (ChaCha20-Poly1305)
|
||||
[ ] FEC repair packet transmission
|
||||
[ ] Foreground service for background calls
|
||||
[ ] Reconnection on network change
|
||||
```
|
||||
|
||||
### Phase 2: Quality & Polish
|
||||
|
||||
```
|
||||
[ ] Adaptive quality (GOOD → DEGRADED → CATASTROPHIC switching)
|
||||
[ ] Quality reports in MediaPacket headers
|
||||
[ ] Network path quality display (real RTT, loss, jitter)
|
||||
[ ] Settings screen (relay, room, seed persistence)
|
||||
[ ] Bluetooth/wired headset audio routing
|
||||
[ ] Rust android_logger for debugging
|
||||
```
|
||||
|
||||
### Phase 3: Production Features
|
||||
|
||||
```
|
||||
[ ] featherChat authentication
|
||||
[ ] Persistent identity (Android Keystore)
|
||||
[ ] Push notifications for incoming calls
|
||||
[ ] Multi-party rooms (already supported by relay)
|
||||
[ ] Call transfer
|
||||
[ ] End-to-end encryption (bypass relay decryption)
|
||||
```
|
||||
|
||||
## Dependency Upgrade Path
|
||||
|
||||
### quinn 0.11 → 0.12 (when released)
|
||||
|
||||
Quinn 0.12 will likely require rustls 0.24. Update both together:
|
||||
1. `Cargo.toml`: bump quinn and rustls versions
|
||||
2. Check `client_config()` and `server_config()` in wzp-transport for API changes
|
||||
3. DATAGRAM API may change — check `send_datagram()` / `read_datagram()`
|
||||
|
||||
### Compose BOM 2024.01 → 2025.x
|
||||
|
||||
The `LinearProgressIndicator` `progress` parameter changed from `Float` to `() -> Float` in Material3 1.2+. If upgrading the BOM:
|
||||
|
||||
```kotlin
|
||||
// Old (current):
|
||||
LinearProgressIndicator(progress = level, ...)
|
||||
|
||||
// New (Material3 1.2+):
|
||||
LinearProgressIndicator(progress = { level }, ...)
|
||||
```
|
||||
|
||||
### Kotlin 1.9 → 2.x
|
||||
|
||||
Kotlin 2.0 changed the Compose compiler plugin. Update `kotlinCompilerExtensionVersion` in `composeOptions` and the Kotlin Gradle plugin version together.
|
||||
BIN
images/photo_2026-04-05_16-03-40.jpg
Normal file
BIN
images/photo_2026-04-05_16-03-40.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
97
issues/001-libc++-shared-crash.md
Normal file
97
issues/001-libc++-shared-crash.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Issue 001: App crashes on launch — C++ runtime not linked correctly
|
||||
|
||||
## Status: Fix v2 committed, needs rebuild
|
||||
|
||||
## Symptom
|
||||
|
||||
App opens, shows splash screen, then immediately crashes back to home screen.
|
||||
Crash occurs when user taps CALL (which triggers `System.loadLibrary("wzp_android")`),
|
||||
or on any code path that first loads the native library.
|
||||
|
||||
## Device tested
|
||||
|
||||
- Nothing Phone, arm64-v8a, Android 15
|
||||
- ADB device ID: `00142151B000973`
|
||||
|
||||
## Crash history
|
||||
|
||||
### Attempt 1: `libc++_shared.so` not found
|
||||
|
||||
```
|
||||
E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: library "libc++_shared.so"
|
||||
not found: needed by .../libwzp_android.so
|
||||
at com.wzp.engine.WzpEngine.<clinit>(WzpEngine.kt:115)
|
||||
```
|
||||
|
||||
**Cause**: `cc::Build` defaults to dynamic C++ linking. `libc++_shared.so` never
|
||||
packaged into APK.
|
||||
|
||||
**Attempted fix**: `.cpp_link_stdlib(Some("c++_static"))` — link STL statically.
|
||||
|
||||
### Attempt 2: missing `__class_type_info` vtable (RTTI)
|
||||
|
||||
```
|
||||
E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol
|
||||
"_ZTVN10__cxxabiv117__class_type_infoE" referenced by .../libwzp_android.so
|
||||
at com.wzp.engine.WzpEngine.<clinit>(WzpEngine.kt:115)
|
||||
```
|
||||
|
||||
**Cause**: Android NDK splits the static C++ runtime into two archives:
|
||||
- `libc++_static.a` — STL (containers, strings, algorithms)
|
||||
- `libc++abi.a` — ABI layer (RTTI typeinfo vtables, exception handling)
|
||||
|
||||
The `cc` crate's `.cpp_link_stdlib(Some("c++_static"))` only emits
|
||||
`cargo:rustc-link-lib=static=c++_static`. It does NOT pull in `libc++abi.a`,
|
||||
so all RTTI symbols (`__class_type_info`, `__si_class_type_info`, etc.)
|
||||
are unresolved at dlopen time.
|
||||
|
||||
## Root cause (full)
|
||||
|
||||
`crates/wzp-android/build.rs` uses the `cc` crate to compile C++17 code
|
||||
(the Oboe audio bridge). Two things go wrong:
|
||||
|
||||
1. Dynamic linking by default → `libc++_shared.so` not in APK
|
||||
2. Even with `.cpp_link_stdlib("c++_static")`, the ABI half (`libc++abi.a`)
|
||||
is not linked, leaving RTTI symbols unresolved
|
||||
|
||||
## Fix (v2)
|
||||
|
||||
Suppress the `cc` crate's automatic C++ stdlib linking with `.cpp_link_stdlib(None)`,
|
||||
then explicitly link both static archives:
|
||||
|
||||
```rust
|
||||
cc::Build::new()
|
||||
.cpp(true)
|
||||
.std("c++17")
|
||||
.cpp_link_stdlib(None) // suppress cc crate's automatic linking
|
||||
.file("cpp/oboe_bridge.cpp")
|
||||
// ...
|
||||
.compile("oboe_bridge");
|
||||
|
||||
// Manually link both halves of the Android NDK static C++ runtime
|
||||
println!("cargo:rustc-link-lib=static=c++_static");
|
||||
println!("cargo:rustc-link-lib=static=c++abi");
|
||||
```
|
||||
|
||||
This is placed once after the match block (applies to both Oboe and stub paths).
|
||||
|
||||
### Trade-off
|
||||
|
||||
Static linking increases `libwzp_android.so` by ~300-500KB. Acceptable for
|
||||
avoiding shared library packaging complexity.
|
||||
|
||||
## Rebuild steps
|
||||
|
||||
```bash
|
||||
cd android && ./gradlew clean assembleRelease
|
||||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
Use `clean` to ensure the native library is fully relinked.
|
||||
|
||||
## Verification
|
||||
|
||||
After install, the app should:
|
||||
1. Open without crashing
|
||||
2. Load `libwzp_android.so` successfully (no UnsatisfiedLinkError in logcat)
|
||||
3. Show the call UI with CALL button
|
||||
75
scripts/Dockerfile.android-builder
Normal file
75
scripts/Dockerfile.android-builder
Normal file
@@ -0,0 +1,75 @@
|
||||
# =============================================================================
|
||||
# WZ Phone — Android build environment (Debian 12 / Bookworm)
|
||||
#
|
||||
# Matches the bare-metal build-android.sh environment:
|
||||
# - Debian 12 (cmake 3.25, no Android cross-compilation bugs)
|
||||
# - JDK 17 (Gradle 8.5 + AGP 8.2.0 compatible)
|
||||
# - NDK 26.1 (last stable before scudo/MTE crash on NDK 27+)
|
||||
# - Rust stable with aarch64-linux-android target + cargo-ndk
|
||||
#
|
||||
# Build: docker build -t wzp-android-builder -f Dockerfile.android-builder .
|
||||
# =============================================================================
|
||||
FROM debian:bookworm
|
||||
|
||||
ARG NDK_VERSION=26.1.10909125
|
||||
ARG ANDROID_API=34
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ANDROID_HOME=/opt/android-sdk \
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
|
||||
ENV ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION \
|
||||
ANDROID_NDK=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
|
||||
# ── System packages ──────────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
curl \
|
||||
git \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
unzip \
|
||||
wget \
|
||||
zip \
|
||||
openjdk-17-jdk-headless \
|
||||
ca-certificates \
|
||||
libasound2-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Android SDK + NDK 26.1 ──────────────────────────────────────────────────
|
||||
RUN mkdir -p $ANDROID_HOME/cmdline-tools \
|
||||
&& cd /tmp \
|
||||
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip \
|
||||
&& unzip -qo cmdtools.zip -d $ANDROID_HOME/cmdline-tools \
|
||||
&& mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \
|
||||
&& rm cmdtools.zip
|
||||
|
||||
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 \
|
||||
&& $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install \
|
||||
"platforms;android-${ANDROID_API}" \
|
||||
"build-tools;${ANDROID_API}.0.0" \
|
||||
"ndk;${NDK_VERSION}" \
|
||||
"platform-tools" \
|
||||
2>&1 | grep -v '^\[' > /dev/null
|
||||
|
||||
# Make SDK world-readable so builder user can access it
|
||||
RUN chmod -R a+rX $ANDROID_HOME
|
||||
|
||||
# ── Builder user (1000:1000) ─────────────────────────────────────────────────
|
||||
RUN groupadd -g 1000 builder \
|
||||
&& useradd -m -u 1000 -g 1000 -s /bin/bash builder
|
||||
|
||||
USER builder
|
||||
WORKDIR /home/builder
|
||||
|
||||
# ── Rust toolchain ───────────────────────────────────────────────────────────
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain stable \
|
||||
&& . $HOME/.cargo/env \
|
||||
&& rustup target add aarch64-linux-android \
|
||||
&& cargo install cargo-ndk
|
||||
|
||||
ENV PATH="/home/builder/.cargo/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$JAVA_HOME/bin:$PATH"
|
||||
|
||||
WORKDIR /build/source
|
||||
159
scripts/build-and-notify.sh
Executable file
159
scripts/build-and-notify.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build Android APK via Docker on SepehrHomeserverdk, upload to rustypaste,
|
||||
# notify via ntfy.sh/wzp. Fire and forget.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-and-notify.sh Build + upload + notify
|
||||
# ./scripts/build-and-notify.sh --rust Force Rust rebuild
|
||||
# ./scripts/build-and-notify.sh --pull Git pull before building
|
||||
# ./scripts/build-and-notify.sh --install Also download + adb install locally
|
||||
|
||||
REMOTE_HOST="SepehrHomeserverdk"
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/android-apk"
|
||||
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
|
||||
|
||||
REBUILD_RUST=0
|
||||
DO_PULL=0
|
||||
DO_INSTALL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--rust) REBUILD_RUST=1 ;;
|
||||
--pull) DO_PULL=1 ;;
|
||||
--install) DO_INSTALL=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
|
||||
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
|
||||
|
||||
# Upload the remote build script
|
||||
log "Uploading build script to remote..."
|
||||
ssh_cmd "cat > /tmp/wzp-docker-build.sh" <<'REMOTE_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
REBUILD_RUST="${1:-0}"
|
||||
DO_PULL="${2:-0}"
|
||||
|
||||
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
trap 'notify "WZP Android build FAILED! Check /tmp/wzp-build.log"' ERR
|
||||
|
||||
# Pull if requested
|
||||
if [ "$DO_PULL" = "1" ]; then
|
||||
echo ">>> Pulling latest..."
|
||||
cd "$BASE_DIR/data/source"
|
||||
git checkout -- . 2>/dev/null || true
|
||||
git pull origin feat/android-voip-client 2>&1 | tail -3
|
||||
fi
|
||||
|
||||
# Clean Rust if requested
|
||||
if [ "$REBUILD_RUST" = "1" ]; then
|
||||
echo ">>> Cleaning Rust target..."
|
||||
rm -rf "$BASE_DIR/data/cache/target/aarch64-linux-android/release"
|
||||
fi
|
||||
|
||||
# Fix perms
|
||||
find "$BASE_DIR/data/source" "$BASE_DIR/data/cache" \
|
||||
! -user 1000 -o ! -group 1000 2>/dev/null | \
|
||||
xargs -r chown 1000:1000 2>/dev/null || true
|
||||
|
||||
# Clean jniLibs
|
||||
rm -rf "$BASE_DIR/data/source/android/app/src/main/jniLibs/arm64-v8a"
|
||||
|
||||
notify "WZP build started..."
|
||||
|
||||
echo ">>> Building in Docker..."
|
||||
docker run --rm --user 1000:1000 \
|
||||
-v "$BASE_DIR/data/source:/build/source" \
|
||||
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
|
||||
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
|
||||
-v "$BASE_DIR/data/cache/target:/build/source/target" \
|
||||
-v "$BASE_DIR/data/cache/gradle:/home/builder/.gradle" \
|
||||
wzp-android-builder bash -c '
|
||||
set -euo pipefail
|
||||
cd /build/source
|
||||
|
||||
echo ">>> Rust build..."
|
||||
cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --release -p wzp-android 2>&1 | tail -5
|
||||
|
||||
echo ">>> Checking .so files..."
|
||||
# cargo-ndk may not copy libc++_shared.so — grab it from the NDK if missing
|
||||
if [ ! -f android/app/src/main/jniLibs/arm64-v8a/libc++_shared.so ]; then
|
||||
echo ">>> libc++_shared.so missing, copying from NDK..."
|
||||
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1)
|
||||
if [ -n "$NDK_LIBCXX" ]; then
|
||||
cp "$NDK_LIBCXX" android/app/src/main/jniLibs/arm64-v8a/
|
||||
echo "Copied from: $NDK_LIBCXX"
|
||||
else
|
||||
echo "WARNING: libc++_shared.so not found in NDK, APK may crash at runtime"
|
||||
fi
|
||||
fi
|
||||
ls -lh android/app/src/main/jniLibs/arm64-v8a/
|
||||
[ -f android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ] || { echo "ERROR: libwzp_android.so missing!"; exit 1; }
|
||||
|
||||
echo ">>> APK build..."
|
||||
cd android && chmod +x gradlew
|
||||
./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -3
|
||||
echo "APK_BUILT"
|
||||
'
|
||||
|
||||
# Upload to rustypaste
|
||||
echo ">>> Uploading to rustypaste..."
|
||||
source "$BASE_DIR/.env"
|
||||
APK=$(find "$BASE_DIR/data/source/android" -name "app-debug*.apk" -path "*/outputs/apk/*" | head -1)
|
||||
if [ -n "$APK" ]; then
|
||||
URL=$(curl -s -F "file=@$APK" -H "Authorization: $rusty_auth_token" "$rusty_address")
|
||||
echo "UPLOAD_URL=$URL"
|
||||
notify "WZP build done! APK: $URL"
|
||||
echo ">>> Done! APK at: $URL"
|
||||
else
|
||||
notify "WZP build FAILED - no APK"
|
||||
echo "ERROR: No APK found"
|
||||
exit 1
|
||||
fi
|
||||
REMOTE_SCRIPT
|
||||
|
||||
ssh_cmd "chmod +x /tmp/wzp-docker-build.sh"
|
||||
|
||||
# Run in tmux
|
||||
log "Starting build in tmux..."
|
||||
ssh_cmd "tmux kill-session -t wzp-build 2>/dev/null; true"
|
||||
ssh_cmd "tmux new-session -d -s wzp-build '/tmp/wzp-docker-build.sh $REBUILD_RUST $DO_PULL 2>&1 | tee /tmp/wzp-build.log'"
|
||||
|
||||
log "Build running! You'll get a notification on ntfy.sh/wzp with the download URL."
|
||||
echo ""
|
||||
echo " Monitor: ssh $REMOTE_HOST 'tail -f /tmp/wzp-build.log'"
|
||||
echo " Status: ssh $REMOTE_HOST 'tail -5 /tmp/wzp-build.log'"
|
||||
echo ""
|
||||
|
||||
# Optionally wait and install locally
|
||||
if [ "$DO_INSTALL" = "1" ]; then
|
||||
log "Waiting for build to finish..."
|
||||
while true; do
|
||||
sleep 15
|
||||
if ssh_cmd "grep -q 'UPLOAD_URL\|ERROR' /tmp/wzp-build.log 2>/dev/null"; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
URL=$(ssh_cmd "grep UPLOAD_URL /tmp/wzp-build.log | tail -1 | cut -d= -f2")
|
||||
if [ -n "$URL" ]; then
|
||||
log "Downloading APK..."
|
||||
mkdir -p "$LOCAL_OUTPUT"
|
||||
curl -s -o "$LOCAL_OUTPUT/wzp-debug.apk" "$URL"
|
||||
log "Installing..."
|
||||
adb uninstall com.wzp.phone 2>/dev/null || true
|
||||
adb install "$LOCAL_OUTPUT/wzp-debug.apk"
|
||||
log "Done!"
|
||||
else
|
||||
err "Build failed"
|
||||
fi
|
||||
fi
|
||||
376
scripts/build-android-cloud.sh
Executable file
376
scripts/build-android-cloud.sh
Executable file
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build WarzonePhone Android APK using a temporary Hetzner Cloud VPS.
|
||||
# Creates a VM, builds both debug and release APKs, downloads them, destroys the VM.
|
||||
#
|
||||
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-android-cloud.sh Full build (create → build → download → destroy)
|
||||
# ./scripts/build-android-cloud.sh --prepare Create VM and install deps only
|
||||
# ./scripts/build-android-cloud.sh --build Build on existing VM
|
||||
# ./scripts/build-android-cloud.sh --transfer Download APKs from VM
|
||||
# ./scripts/build-android-cloud.sh --destroy Delete the VM
|
||||
# ./scripts/build-android-cloud.sh --all prepare + build + transfer (VM persists)
|
||||
# ./scripts/build-android-cloud.sh --upload Re-upload source to existing VM
|
||||
#
|
||||
# Environment variables (all optional):
|
||||
# WZP_BRANCH Branch to build (default: feat/android-voip-client)
|
||||
# WZP_SERVER_TYPE Hetzner server type (default: cx32 — 4 vCPU, 8GB RAM)
|
||||
# WZP_KEEP_VM Set to 1 to skip destroy on full build
|
||||
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="${WZP_SERVER_TYPE:-cx33}"
|
||||
IMAGE="ubuntu-24.04"
|
||||
SERVER_NAME="wzp-android-builder"
|
||||
REMOTE_USER="root"
|
||||
OUTPUT_DIR="target/android-apk"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
|
||||
KEEP_VM="${WZP_KEEP_VM:-0}"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"
|
||||
|
||||
# NDK 26.1 — NDK 27 crashes scudo on Android 16 MTE devices
|
||||
NDK_VERSION="26.1.10909125"
|
||||
ANDROID_API="34"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||
die() { err "$@"; do_destroy_quiet; exit 1; }
|
||||
|
||||
get_vm_ip() {
|
||||
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "No VM found. Run --prepare first."
|
||||
ssh $SSH_OPTS -A -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
scp_down() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "No VM found."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2"
|
||||
}
|
||||
|
||||
do_destroy_quiet() {
|
||||
local name
|
||||
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -n "$name" ]; then
|
||||
echo ""
|
||||
err "Cleaning up — destroying VM $name"
|
||||
hcloud server delete "$name" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --prepare: Create VM, install all build dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_prepare() {
|
||||
# Check if VM already exists
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -n "$existing" ]; then
|
||||
log "VM already exists: $existing — reusing"
|
||||
do_upload
|
||||
return
|
||||
fi
|
||||
|
||||
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create \
|
||||
--name "$SERVER_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location fsn1 \
|
||||
--quiet \
|
||||
|| die "Failed to create VM"
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "VM created but no IP found"
|
||||
echo " VM: $SERVER_NAME @ $ip"
|
||||
|
||||
# Wait for SSH
|
||||
log "Waiting for SSH..."
|
||||
local ok=0
|
||||
for i in $(seq 1 30); do
|
||||
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$ok" -eq 1 ] || die "SSH timeout after 60s"
|
||||
|
||||
# System packages
|
||||
log "Installing system packages (cmake, JDK 17, build tools)..."
|
||||
ssh_cmd "export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get update -qq && \
|
||||
apt-get install -y -qq \
|
||||
build-essential cmake curl git libssl-dev pkg-config \
|
||||
unzip wget zip openjdk-17-jdk-headless \
|
||||
> /dev/null 2>&1" \
|
||||
|| die "Failed to install system packages"
|
||||
|
||||
# Verify cmake version (must be <= 3.30)
|
||||
local cmake_ver
|
||||
cmake_ver=$(ssh_cmd "cmake --version | head -1")
|
||||
echo " cmake: $cmake_ver"
|
||||
echo " java: $(ssh_cmd "java -version 2>&1 | head -1")"
|
||||
|
||||
# Rust
|
||||
log "Installing Rust toolchain..."
|
||||
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" \
|
||||
|| die "Failed to install Rust"
|
||||
ssh_cmd "source \$HOME/.cargo/env && rustup target add aarch64-linux-android > /dev/null 2>&1"
|
||||
ssh_cmd "source \$HOME/.cargo/env && cargo install cargo-ndk > /dev/null 2>&1" \
|
||||
|| die "Failed to install cargo-ndk"
|
||||
echo " rust: $(ssh_cmd "source \$HOME/.cargo/env && rustc --version")"
|
||||
|
||||
# Android SDK + NDK
|
||||
log "Installing Android SDK + NDK $NDK_VERSION..."
|
||||
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||
mkdir -p \$HOME/android-sdk/cmdline-tools && \
|
||||
cd /tmp && \
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip && \
|
||||
unzip -qo cmdtools.zip -d \$HOME/android-sdk/cmdline-tools && \
|
||||
mv \$HOME/android-sdk/cmdline-tools/cmdline-tools \$HOME/android-sdk/cmdline-tools/latest 2>/dev/null; \
|
||||
yes | \$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1; \
|
||||
\$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \
|
||||
'platforms;android-${ANDROID_API}' \
|
||||
'build-tools;${ANDROID_API}.0.0' \
|
||||
'ndk;${NDK_VERSION}' \
|
||||
'platform-tools' \
|
||||
2>&1 | grep -v '^\[' > /dev/null" \
|
||||
|| die "Failed to install Android SDK/NDK"
|
||||
|
||||
ssh_cmd "[ -d \$HOME/android-sdk/ndk/$NDK_VERSION ]" \
|
||||
|| die "NDK not found after install"
|
||||
echo " NDK: $NDK_VERSION"
|
||||
|
||||
# Upload source
|
||||
do_upload
|
||||
|
||||
log "VM ready!"
|
||||
echo " IP: $ip"
|
||||
echo " SSH: ssh -A -i $SSH_KEY_PATH root@$ip"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --upload: Upload source code to VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_upload() {
|
||||
log "Uploading source code (rsync)..."
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || die "No VM found."
|
||||
rsync -az --delete \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='.claude' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='dist' \
|
||||
--exclude='desktop/src-tauri/gen' \
|
||||
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
|
||||
"$PROJECT_DIR/" "$REMOTE_USER@$ip:/root/wzp-build/"
|
||||
echo " Source uploaded."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --build: Build native .so + debug & release APKs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_build() {
|
||||
log "Building Rust native library (arm64-v8a, release)..."
|
||||
|
||||
# Clean Rust release target to force full rebuild.
|
||||
# cargo-ndk only copies libc++_shared.so when it actually links — a partial
|
||||
# clean that skips relinking leaves libc++_shared.so missing from jniLibs.
|
||||
ssh_cmd "rm -rf /root/wzp-build/target/aarch64-linux-android/release \
|
||||
/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a"
|
||||
|
||||
# ANDROID_NDK must be set (not just ANDROID_NDK_HOME) — cmake checks it
|
||||
ssh_cmd "source \$HOME/.cargo/env && \
|
||||
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||
export ANDROID_NDK_HOME=\$ANDROID_HOME/ndk/$NDK_VERSION && \
|
||||
export ANDROID_NDK=\$ANDROID_NDK_HOME && \
|
||||
cd /root/wzp-build && \
|
||||
cargo ndk -t arm64-v8a \
|
||||
-o android/app/src/main/jniLibs \
|
||||
build --release -p wzp-android 2>&1" | tail -5 \
|
||||
|| die "Rust native build failed"
|
||||
|
||||
ssh_cmd "[ -f /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ]" \
|
||||
|| die "libwzp_android.so not found after build"
|
||||
|
||||
local so_size
|
||||
so_size=$(ssh_cmd "du -h /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1")
|
||||
echo " .so: $so_size"
|
||||
|
||||
# Generate debug keystore if missing
|
||||
ssh_cmd "[ -f /root/wzp-build/android/keystore/wzp-debug.jks ] || \
|
||||
(mkdir -p /root/wzp-build/android/keystore && \
|
||||
keytool -genkey -v \
|
||||
-keystore /root/wzp-build/android/keystore/wzp-debug.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias wzp-debug -storepass android -keypass android \
|
||||
-dname 'CN=WZP Debug' > /dev/null 2>&1)"
|
||||
|
||||
# Build debug APK
|
||||
log "Building debug APK..."
|
||||
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||
cd /root/wzp-build/android && \
|
||||
chmod +x ./gradlew && \
|
||||
./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1" | tail -3 \
|
||||
|| die "Debug APK build failed"
|
||||
|
||||
# Build release APK (uses debug keystore for now)
|
||||
log "Building release APK..."
|
||||
# Copy debug keystore as release keystore (same password in build.gradle)
|
||||
ssh_cmd "cp /root/wzp-build/android/keystore/wzp-debug.jks /root/wzp-build/android/keystore/wzp-release.jks 2>/dev/null; true"
|
||||
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
|
||||
export ANDROID_HOME=\$HOME/android-sdk && \
|
||||
cd /root/wzp-build/android && \
|
||||
./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1" | tail -3 \
|
||||
|| echo " (release APK failed — debug APK still available)"
|
||||
|
||||
log "Build complete!"
|
||||
ssh_cmd "find /root/wzp-build/android -name '*.apk' -path '*/outputs/apk/*' -exec ls -lh {} \;"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --transfer: Download APKs to local machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_transfer() {
|
||||
log "Downloading APKs..."
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
|
||||
# Debug APK
|
||||
local debug_apk
|
||||
debug_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-debug*.apk' -path '*/outputs/apk/*' | head -1")
|
||||
if [ -n "$debug_apk" ]; then
|
||||
scp_down "$debug_apk" "$OUTPUT_DIR/wzp-debug.apk"
|
||||
echo " debug: $OUTPUT_DIR/wzp-debug.apk ($(du -h "$OUTPUT_DIR/wzp-debug.apk" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Release APK
|
||||
local release_apk
|
||||
release_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-release*.apk' -path '*/outputs/apk/*' | head -1" || true)
|
||||
if [ -n "$release_apk" ]; then
|
||||
scp_down "$release_apk" "$OUTPUT_DIR/wzp-release.apk"
|
||||
echo " release: $OUTPUT_DIR/wzp-release.apk ($(du -h "$OUTPUT_DIR/wzp-release.apk" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Also copy the .so for inspection
|
||||
scp_down "/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" "$OUTPUT_DIR/libwzp_android.so"
|
||||
echo " .so: $OUTPUT_DIR/libwzp_android.so"
|
||||
|
||||
log "Transfer complete!"
|
||||
echo ""
|
||||
echo " Install debug: adb install -r $OUTPUT_DIR/wzp-debug.apk"
|
||||
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && echo " Install release: adb install -r $OUTPUT_DIR/wzp-release.apk"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --destroy: Delete the VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_destroy() {
|
||||
local name
|
||||
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -z "$name" ]; then
|
||||
echo "No VM to destroy."
|
||||
return
|
||||
fi
|
||||
log "Deleting VM: $name"
|
||||
hcloud server delete "$name"
|
||||
echo " Done."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full build: create → build → transfer → destroy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_full() {
|
||||
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
|
||||
|
||||
do_prepare
|
||||
|
||||
# Disable trap during build — release APK failure is non-fatal
|
||||
trap - ERR
|
||||
do_build
|
||||
do_transfer
|
||||
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
|
||||
|
||||
if [ "$KEEP_VM" = "1" ]; then
|
||||
log "VM kept alive (WZP_KEEP_VM=1). Destroy with: $0 --destroy"
|
||||
else
|
||||
do_destroy
|
||||
fi
|
||||
|
||||
log "All done!"
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────────┐"
|
||||
echo " │ Debug APK: $OUTPUT_DIR/wzp-debug.apk"
|
||||
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && \
|
||||
echo " │ Release APK: $OUTPUT_DIR/wzp-release.apk"
|
||||
echo " │"
|
||||
echo " │ Install: adb install -r $OUTPUT_DIR/wzp-debug.apk"
|
||||
echo " └──────────────────────────────────────────────────┘"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--prepare) do_prepare ;;
|
||||
--build) do_build ;;
|
||||
--transfer) do_transfer ;;
|
||||
--destroy) do_destroy ;;
|
||||
--upload) do_upload ;;
|
||||
--all)
|
||||
do_prepare
|
||||
do_build
|
||||
do_transfer
|
||||
log "VM still running. Destroy with: $0 --destroy"
|
||||
;;
|
||||
"")
|
||||
do_full
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--prepare|--build|--transfer|--destroy|--all|--upload]"
|
||||
echo ""
|
||||
echo " (no args) Full build: create VM → build → download → destroy VM"
|
||||
echo " --prepare Create VM and install deps"
|
||||
echo " --build Build on existing VM"
|
||||
echo " --transfer Download APKs from VM"
|
||||
echo " --destroy Delete the VM"
|
||||
echo " --all prepare + build + transfer (VM persists)"
|
||||
echo " --upload Re-upload source to existing VM"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " WZP_BRANCH=$BRANCH"
|
||||
echo " WZP_SERVER_TYPE=$SERVER_TYPE"
|
||||
echo " WZP_KEEP_VM=$KEEP_VM (set to 1 to skip auto-destroy)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
416
scripts/build-android-docker.sh
Executable file
416
scripts/build-android-docker.sh
Executable file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =============================================================================
|
||||
# WZ Phone — Android APK build via Docker on remote host
|
||||
#
|
||||
# Replaces Hetzner Cloud VMs with a Docker container on SepehrHomeserverdk.
|
||||
# Persistent storage at /mnt/storage/manBuilder/data/{source,cache,keystore}.
|
||||
# Uploads APKs to rustypaste, then SCPs them back locally.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - SSH config has "SepehrHomeserverdk" host entry
|
||||
# - SSH agent running with keys for both remote host and git.manko.yoga
|
||||
# - Docker installed on remote host
|
||||
# - /mnt/storage/manBuilder/.env with rusty_address and rusty_auth_token
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-android-docker.sh Full: prepare+pull+build+upload+transfer
|
||||
# ./scripts/build-android-docker.sh --prepare Build Docker image + sync keystores
|
||||
# ./scripts/build-android-docker.sh --pull Clone/update source from Gitea
|
||||
# ./scripts/build-android-docker.sh --build Build debug APK inside Docker
|
||||
# ./scripts/build-android-docker.sh --upload Upload APKs to rustypaste
|
||||
# ./scripts/build-android-docker.sh --transfer SCP APKs back to local machine
|
||||
# ./scripts/build-android-docker.sh --all pull+build+upload+transfer (image ready)
|
||||
#
|
||||
# Add --release to also build release APK:
|
||||
# ./scripts/build-android-docker.sh --build --release
|
||||
# ./scripts/build-android-docker.sh --all --release
|
||||
# ./scripts/build-android-docker.sh --release (full pipeline, debug+release)
|
||||
#
|
||||
# Environment variables (all optional):
|
||||
# WZP_BRANCH Branch to build (default: feat/android-voip-client)
|
||||
# =============================================================================
|
||||
|
||||
REMOTE_HOST="SepehrHomeserverdk"
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
REPO_URL="ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git"
|
||||
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
|
||||
DOCKER_IMAGE="wzp-android-builder"
|
||||
LOCAL_OUTPUT_DIR="target/android-apk"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
LOCAL_KEYSTORE_DIR="$PROJECT_DIR/android/keystore"
|
||||
|
||||
SSH_OPTS="-o ConnectTimeout=10 -o LogLevel=ERROR -o ServerAliveInterval=15 -o ServerAliveCountMax=4"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||
|
||||
ssh_cmd() {
|
||||
ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"
|
||||
}
|
||||
|
||||
push_reminder() {
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────────────────────────┐"
|
||||
echo " │ IMPORTANT: Push your changes to origin (Gitea) before build! │"
|
||||
echo " │ │"
|
||||
echo " │ The build fetches from: │"
|
||||
echo " │ ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git │"
|
||||
echo " │ │"
|
||||
echo " │ Run: git push origin $BRANCH"
|
||||
echo " └──────────────────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
read -r -p "Press Enter to continue (Ctrl-C to abort)... "
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --prepare: Create remote dirs, build Docker image, sync keystores
|
||||
# ---------------------------------------------------------------------------
|
||||
do_prepare() {
|
||||
log "Preparing remote environment..."
|
||||
ssh_cmd "mkdir -p $BASE_DIR/data/{source,cache/cargo-registry,cache/cargo-git,cache/target,cache/gradle,keystore}"
|
||||
|
||||
# Sync keystores (gitignored — won't exist after clone)
|
||||
REMOTE_HAS_KEYSTORE=$(ssh_cmd "[ -f $BASE_DIR/data/keystore/wzp-debug.jks ] && echo yes || echo no")
|
||||
if [ "$REMOTE_HAS_KEYSTORE" = "no" ]; then
|
||||
if [ -f "$LOCAL_KEYSTORE_DIR/wzp-debug.jks" ]; then
|
||||
log "Uploading keystores to remote persistent storage..."
|
||||
scp $SSH_OPTS \
|
||||
"$LOCAL_KEYSTORE_DIR/wzp-debug.jks" \
|
||||
"$LOCAL_KEYSTORE_DIR/wzp-release.jks" \
|
||||
"$REMOTE_HOST:$BASE_DIR/data/keystore/"
|
||||
echo " Keystores uploaded to $BASE_DIR/data/keystore/"
|
||||
else
|
||||
err "No keystores found locally at $LOCAL_KEYSTORE_DIR/"
|
||||
err "Build will generate a temporary debug keystore instead."
|
||||
fi
|
||||
else
|
||||
echo " Keystores already on remote."
|
||||
fi
|
||||
|
||||
# Upload Dockerfile from local (always use local version — no git dependency)
|
||||
log "Uploading Dockerfile to remote..."
|
||||
ssh_cmd "mkdir -p $BASE_DIR/data/source/scripts"
|
||||
scp $SSH_OPTS \
|
||||
"$PROJECT_DIR/scripts/Dockerfile.android-builder" \
|
||||
"$REMOTE_HOST:$BASE_DIR/data/source/scripts/Dockerfile.android-builder"
|
||||
|
||||
# Build Docker image
|
||||
log "Building Docker image (Debian 12 + Rust + Android SDK/NDK)..."
|
||||
ssh_cmd bash <<IMAGE_EOF
|
||||
set -euo pipefail
|
||||
docker build -t "$DOCKER_IMAGE" - < "$BASE_DIR/data/source/scripts/Dockerfile.android-builder"
|
||||
echo " Docker image '$DOCKER_IMAGE' ready."
|
||||
IMAGE_EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --pull: Clone or update source from Gitea
|
||||
# ---------------------------------------------------------------------------
|
||||
do_pull() {
|
||||
push_reminder
|
||||
|
||||
log "Updating source (branch: $BRANCH)..."
|
||||
ssh_cmd bash <<PULL_EOF
|
||||
set -euo pipefail
|
||||
mkdir -p "$BASE_DIR/data/source" \
|
||||
"$BASE_DIR/data/cache/cargo-registry" \
|
||||
"$BASE_DIR/data/cache/cargo-git" \
|
||||
"$BASE_DIR/data/cache/target" \
|
||||
"$BASE_DIR/data/cache/gradle" \
|
||||
"$BASE_DIR/data/keystore"
|
||||
cd "$BASE_DIR/data/source"
|
||||
if [ -d .git ]; then
|
||||
echo " Fetching origin..."
|
||||
git fetch origin
|
||||
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
|
||||
git reset --hard "origin/$BRANCH"
|
||||
else
|
||||
echo " Cloning repo..."
|
||||
cd "$BASE_DIR/data"
|
||||
rm -rf source
|
||||
git clone --branch "$BRANCH" "$REPO_URL" source
|
||||
cd source
|
||||
fi
|
||||
git submodule update --init || true
|
||||
echo " HEAD: \$(git log --oneline -1)"
|
||||
echo " Branch: \$(git branch --show-current)"
|
||||
PULL_EOF
|
||||
|
||||
# Inject keystores into source tree
|
||||
log "Injecting keystores into source tree..."
|
||||
ssh_cmd bash <<KS_EOF
|
||||
set -euo pipefail
|
||||
mkdir -p "$BASE_DIR/data/source/android/keystore"
|
||||
if [ -f "$BASE_DIR/data/keystore/wzp-debug.jks" ]; then
|
||||
cp "$BASE_DIR/data/keystore/wzp-debug.jks" "$BASE_DIR/data/source/android/keystore/"
|
||||
cp "$BASE_DIR/data/keystore/wzp-release.jks" "$BASE_DIR/data/source/android/keystore/"
|
||||
echo " Keystores ready (wzp-debug.jks + wzp-release.jks)"
|
||||
else
|
||||
echo " WARNING: No keystores in persistent storage — build will generate temporary ones"
|
||||
fi
|
||||
KS_EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --build: Build APK inside Docker container
|
||||
# $1 = "1" to also build release APK (default: debug only)
|
||||
# ---------------------------------------------------------------------------
|
||||
do_build() {
|
||||
local build_release="${1:-0}"
|
||||
|
||||
if [ "$build_release" = "1" ]; then
|
||||
log "Building debug + release APKs inside Docker container..."
|
||||
else
|
||||
log "Building debug APK inside Docker container..."
|
||||
fi
|
||||
|
||||
ssh_cmd bash <<BUILD_EOF
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure uid 1000 can write to mounted volumes
|
||||
# Use find to only chown files not already 1000:1000, ignore errors on stubborn files
|
||||
find "$BASE_DIR/data/source" "$BASE_DIR/data/cache" \
|
||||
! -user 1000 -o ! -group 1000 2>/dev/null | \
|
||||
xargs -r chown 1000:1000 2>/dev/null || true
|
||||
|
||||
docker run --rm \
|
||||
--user 1000:1000 \
|
||||
-e BUILD_RELEASE="$build_release" \
|
||||
-v "$BASE_DIR/data/source:/build/source" \
|
||||
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
|
||||
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
|
||||
-v "$BASE_DIR/data/cache/target:/build/source/target" \
|
||||
-v "$BASE_DIR/data/cache/gradle:/home/builder/.gradle" \
|
||||
"$DOCKER_IMAGE" \
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
cd /build/source
|
||||
|
||||
echo ">>> Building Rust native library (arm64-v8a, release)..."
|
||||
|
||||
# Clean stale jniLibs so cargo-ndk re-copies libc++_shared.so
|
||||
rm -rf android/app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
cargo ndk -t arm64-v8a \
|
||||
-o android/app/src/main/jniLibs \
|
||||
build --release -p wzp-android 2>&1 | tail -10
|
||||
|
||||
[ -f android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ] || {
|
||||
echo "ERROR: libwzp_android.so not found after build"; exit 1;
|
||||
}
|
||||
echo " .so size: \$(du -h android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1)"
|
||||
|
||||
# Verify keystores exist (should have been injected by --pull)
|
||||
if [ -f android/keystore/wzp-debug.jks ] && [ -f android/keystore/wzp-release.jks ]; then
|
||||
echo " Keystores: wzp-debug.jks + wzp-release.jks (from persistent storage)"
|
||||
else
|
||||
echo "WARNING: Keystores missing — generating temporary debug keystore..."
|
||||
mkdir -p android/keystore
|
||||
keytool -genkey -v \
|
||||
-keystore android/keystore/wzp-debug.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias wzp-debug -storepass android -keypass android \
|
||||
-dname "CN=WZP Debug" 2>&1 | tail -1
|
||||
cp android/keystore/wzp-debug.jks android/keystore/wzp-release.jks
|
||||
fi
|
||||
|
||||
cd android
|
||||
chmod +x ./gradlew
|
||||
|
||||
echo ">>> Building debug APK..."
|
||||
./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -5
|
||||
|
||||
if [ "\${BUILD_RELEASE}" = "1" ]; then
|
||||
echo ">>> Building release APK..."
|
||||
./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1 | tail -5 || \
|
||||
echo " (release build failed — debug APK still available)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo ">>> Build artifacts:"
|
||||
find . -name "*.apk" -path "*/outputs/apk/*" -exec ls -lh {} \;
|
||||
'
|
||||
BUILD_EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --upload: Upload APKs to rustypaste
|
||||
# ---------------------------------------------------------------------------
|
||||
do_upload() {
|
||||
log "Uploading APKs to rustypaste..."
|
||||
|
||||
UPLOAD_RESULT=$(ssh_cmd bash <<'UPLOAD_EOF'
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
ENV_FILE="$BASE_DIR/.env"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "ERROR: $ENV_FILE not found — create it with rusty_address and rusty_auth_token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$ENV_FILE"
|
||||
|
||||
if [ -z "${rusty_address:-}" ] || [ -z "${rusty_auth_token:-}" ]; then
|
||||
echo "ERROR: rusty_address or rusty_auth_token not set in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_apk() {
|
||||
local apk="$1" label="$2"
|
||||
if [ -f "$apk" ]; then
|
||||
local url
|
||||
url=$(curl -s -F "file=@$apk" -H "Authorization: $rusty_auth_token" "$rusty_address")
|
||||
echo "$label: $url"
|
||||
fi
|
||||
}
|
||||
|
||||
DEBUG_APK=$(find "$BASE_DIR/data/source/android" -name "app-debug*.apk" -path "*/outputs/apk/*" 2>/dev/null | head -1)
|
||||
RELEASE_APK=$(find "$BASE_DIR/data/source/android" -name "app-release*.apk" -path "*/outputs/apk/*" 2>/dev/null | head -1)
|
||||
|
||||
upload_apk "${DEBUG_APK:-}" "debug"
|
||||
upload_apk "${RELEASE_APK:-}" "release"
|
||||
UPLOAD_EOF
|
||||
)
|
||||
|
||||
echo "$UPLOAD_RESULT"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --transfer: SCP APKs back to local machine
|
||||
# ---------------------------------------------------------------------------
|
||||
do_transfer() {
|
||||
log "Downloading APKs to local machine..."
|
||||
|
||||
mkdir -p "$LOCAL_OUTPUT_DIR"
|
||||
|
||||
# Debug APK
|
||||
DEBUG_REMOTE=$(ssh_cmd "find $BASE_DIR/data/source/android -name 'app-debug*.apk' -path '*/outputs/apk/*' 2>/dev/null | head -1" || true)
|
||||
if [ -n "$DEBUG_REMOTE" ]; then
|
||||
scp $SSH_OPTS "$REMOTE_HOST:$DEBUG_REMOTE" "$LOCAL_OUTPUT_DIR/wzp-debug.apk"
|
||||
echo " debug: $LOCAL_OUTPUT_DIR/wzp-debug.apk ($(du -h "$LOCAL_OUTPUT_DIR/wzp-debug.apk" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Release APK
|
||||
RELEASE_REMOTE=$(ssh_cmd "find $BASE_DIR/data/source/android -name 'app-release*.apk' -path '*/outputs/apk/*' 2>/dev/null | head -1" || true)
|
||||
if [ -n "$RELEASE_REMOTE" ]; then
|
||||
scp $SSH_OPTS "$REMOTE_HOST:$RELEASE_REMOTE" "$LOCAL_OUTPUT_DIR/wzp-release.apk"
|
||||
echo " release: $LOCAL_OUTPUT_DIR/wzp-release.apk ($(du -h "$LOCAL_OUTPUT_DIR/wzp-release.apk" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Also grab the .so
|
||||
scp $SSH_OPTS "$REMOTE_HOST:$BASE_DIR/data/source/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" \
|
||||
"$LOCAL_OUTPUT_DIR/libwzp_android.so" 2>/dev/null \
|
||||
&& echo " .so: $LOCAL_OUTPUT_DIR/libwzp_android.so" || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary banner
|
||||
# ---------------------------------------------------------------------------
|
||||
show_summary() {
|
||||
log "All done!"
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────────────────────┐"
|
||||
[ -f "$LOCAL_OUTPUT_DIR/wzp-debug.apk" ] && \
|
||||
echo " │ Debug APK: $LOCAL_OUTPUT_DIR/wzp-debug.apk"
|
||||
[ -f "$LOCAL_OUTPUT_DIR/wzp-release.apk" ] && \
|
||||
echo " │ Release APK: $LOCAL_OUTPUT_DIR/wzp-release.apk"
|
||||
echo " │"
|
||||
if [ -n "${UPLOAD_RESULT:-}" ]; then
|
||||
echo " │ Rustypaste:"
|
||||
echo "$UPLOAD_RESULT" | while read -r line; do
|
||||
echo " │ $line"
|
||||
done
|
||||
echo " │"
|
||||
fi
|
||||
echo " │ Install: adb install -r $LOCAL_OUTPUT_DIR/wzp-debug.apk"
|
||||
echo " └──────────────────────────────────────────────────────────────┘"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
ACTION=""
|
||||
BUILD_RELEASE=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--release) BUILD_RELEASE=1 ;;
|
||||
--prepare|--pull|--build|--upload|--transfer|--all)
|
||||
if [ -n "$ACTION" ]; then
|
||||
err "Multiple actions specified: $ACTION and $arg"
|
||||
exit 1
|
||||
fi
|
||||
ACTION="$arg"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--prepare|--pull|--build|--upload|--transfer|--all] [--release]"
|
||||
echo ""
|
||||
echo "Actions:"
|
||||
echo " (no action) Full pipeline: pull → prepare → build → upload → transfer"
|
||||
echo " --prepare Build Docker image + sync keystores to remote"
|
||||
echo " --pull Clone/update source from Gitea + inject keystores"
|
||||
echo " --build Build debug APK inside Docker container"
|
||||
echo " --upload Upload APKs to rustypaste"
|
||||
echo " --transfer SCP APKs + .so back to local machine"
|
||||
echo " --all pull → build → upload → transfer (Docker image ready)"
|
||||
echo ""
|
||||
echo "Flags:"
|
||||
echo " --release Also build release APK (default: debug only)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # full pipeline, debug only"
|
||||
echo " $0 --release # full pipeline, debug + release"
|
||||
echo " $0 --build # debug APK only"
|
||||
echo " $0 --build --release # debug + release APKs"
|
||||
echo " $0 --all # iterate: pull+build+upload+transfer (debug)"
|
||||
echo " $0 --all --release # iterate with release too"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " WZP_BRANCH=$BRANCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
case "${ACTION:-}" in
|
||||
--prepare)
|
||||
do_prepare
|
||||
;;
|
||||
--pull)
|
||||
do_pull
|
||||
;;
|
||||
--build)
|
||||
do_build "$BUILD_RELEASE"
|
||||
;;
|
||||
--upload)
|
||||
do_upload
|
||||
;;
|
||||
--transfer)
|
||||
do_transfer
|
||||
;;
|
||||
--all)
|
||||
do_pull
|
||||
do_build "$BUILD_RELEASE"
|
||||
do_upload
|
||||
do_transfer
|
||||
show_summary
|
||||
;;
|
||||
"")
|
||||
do_pull
|
||||
do_prepare
|
||||
do_build "$BUILD_RELEASE"
|
||||
do_upload
|
||||
do_transfer
|
||||
show_summary
|
||||
;;
|
||||
esac
|
||||
240
scripts/build-android.sh
Executable file
240
scripts/build-android.sh
Executable file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# WZ Phone — Android APK build script for Debian 12 (Bookworm)
|
||||
#
|
||||
# Sets up a complete build environment from scratch and produces a debug APK.
|
||||
# Idempotent — safe to run multiple times (skips already-installed components).
|
||||
#
|
||||
# Tested on: Debian 12 x86_64, cross-compiling to aarch64-linux-android
|
||||
#
|
||||
# Why these specific versions:
|
||||
#
|
||||
# cmake 3.25-3.28 (system package from apt)
|
||||
# cmake 3.25 (Debian 12) and 3.28 (Ubuntu 24.04) both work.
|
||||
# cmake 3.31+ has armv7/aarch64 flag conflicts in Android-Determine.cmake.
|
||||
# cmake 4.x drops cmake_minimum_required < 3.5.
|
||||
# Do NOT use pip cmake — it bundles its own modules with different bugs.
|
||||
# CRITICAL: must set ANDROID_NDK=$ANDROID_NDK_HOME (cmake checks ANDROID_NDK).
|
||||
#
|
||||
# NDK 26.1.10909125 (r26b)
|
||||
# NDK 27+ ships a newer libc++_shared.so with different scudo allocator
|
||||
# defaults. On Android 16 devices with MTE (Memory Tagging Extension)
|
||||
# enabled (e.g. Nothing A059), NDK 27's scudo crashes during malloc/calloc.
|
||||
# NDK 26.1 is the last stable version for these devices.
|
||||
# Matches build.gradle.kts: ndkVersion = "26.1.10909125"
|
||||
#
|
||||
# JDK 17 (openjdk-17-jdk-headless)
|
||||
# Gradle 8.5 + AGP 8.2.0 officially support JDK 17.
|
||||
# JDK 21 works for compilation but has Gradle daemon compat issues.
|
||||
#
|
||||
# Rust stable (currently 1.94.1)
|
||||
# Edition 2024, MSRV 1.85. Stable channel is fine.
|
||||
#
|
||||
# ANDROID_NDK=$ANDROID_NDK_HOME (BOTH must be set)
|
||||
# cmake's Android platform module checks ANDROID_NDK (no _HOME suffix).
|
||||
# cargo-ndk sets ANDROID_NDK_HOME. Both must point to the same path.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x scripts/build-android.sh
|
||||
# ./scripts/build-android.sh # build from current tree
|
||||
# WZP_CLONE=1 ./scripts/build-android.sh # clone fresh from git
|
||||
# WZP_COMMIT=2092245 ./scripts/build-android.sh # pin to specific commit
|
||||
#
|
||||
# Environment variables (all optional):
|
||||
# WZP_CLONE Set to 1 to clone from git instead of using current dir
|
||||
# WZP_REPO Git clone URL (default: ssh://git@git.manko.yoga:222/manawenuz/wz-phone)
|
||||
# WZP_BRANCH Branch to checkout (default: feat/android-voip-client)
|
||||
# WZP_COMMIT Commit to pin to (default: HEAD)
|
||||
# WZP_WORKDIR Build directory (default: /tmp/wzp-build)
|
||||
# ANDROID_API SDK platform level (default: 34)
|
||||
# NDK_VERSION NDK version string (default: 26.1.10909125)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
CLONE="${WZP_CLONE:-0}"
|
||||
REPO="${WZP_REPO:-ssh://git@git.manko.yoga:222/manawenuz/wz-phone}"
|
||||
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
|
||||
COMMIT="${WZP_COMMIT:-}"
|
||||
WORKDIR="${WZP_WORKDIR:-/tmp/wzp-build}"
|
||||
ANDROID_API="${ANDROID_API:-34}"
|
||||
NDK_VERSION="${NDK_VERSION:-26.1.10909125}"
|
||||
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
|
||||
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
# cmake checks ANDROID_NDK (not _HOME) — both must be set
|
||||
ANDROID_NDK="$ANDROID_NDK_HOME"
|
||||
JAVA_HOME="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)"
|
||||
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||
|
||||
export ANDROID_HOME ANDROID_NDK_HOME ANDROID_NDK JAVA_HOME
|
||||
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: System packages (cmake 3.25, JDK 17, make, git, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Installing system packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
build-essential \
|
||||
cmake \
|
||||
curl \
|
||||
git \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
unzip \
|
||||
wget \
|
||||
zip \
|
||||
openjdk-17-jdk-headless \
|
||||
2>/dev/null
|
||||
|
||||
# Verify critical versions
|
||||
log "Verifying build environment"
|
||||
echo " cmake: $(cmake --version | head -1)"
|
||||
echo " java: $(java -version 2>&1 | head -1)"
|
||||
echo " make: $(make --version | head -1)"
|
||||
|
||||
CMAKE_MAJOR=$(cmake --version | head -1 | grep -oP '\d+' | head -1)
|
||||
CMAKE_MINOR=$(cmake --version | head -1 | grep -oP '\d+' | sed -n '2p')
|
||||
if [ "$CMAKE_MAJOR" -gt 3 ] || { [ "$CMAKE_MAJOR" -eq 3 ] && [ "$CMAKE_MINOR" -gt 30 ]; }; then
|
||||
err "cmake $(cmake --version | head -1) is too new! Need cmake <= 3.28.x. cmake 3.31+ has Android cross-compilation bugs."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Rust toolchain
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Setting up Rust toolchain"
|
||||
if ! command -v rustup &>/dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
rustup default stable
|
||||
rustup target add aarch64-linux-android
|
||||
echo " rustc: $(rustc --version)"
|
||||
echo " cargo: $(cargo --version)"
|
||||
|
||||
if ! command -v cargo-ndk &>/dev/null; then
|
||||
log "Installing cargo-ndk"
|
||||
cargo install cargo-ndk
|
||||
fi
|
||||
echo " ndk: $(cargo ndk --version)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: Android SDK + NDK 26.1
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Setting up Android SDK + NDK $NDK_VERSION"
|
||||
if [ ! -f "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
log "Downloading Android command-line tools"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
TMPZIP=$(mktemp /tmp/cmdline-tools-XXXXX.zip)
|
||||
wget -q -O "$TMPZIP" "$CMDLINE_TOOLS_URL"
|
||||
unzip -qo "$TMPZIP" -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" 2>/dev/null || true
|
||||
rm -f "$TMPZIP"
|
||||
fi
|
||||
|
||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
|
||||
if [ ! -d "$ANDROID_NDK_HOME" ]; then
|
||||
log "Installing NDK $NDK_VERSION (this takes a few minutes)"
|
||||
sdkmanager --install \
|
||||
"platforms;android-${ANDROID_API}" \
|
||||
"build-tools;${ANDROID_API}.0.0" \
|
||||
"ndk;${NDK_VERSION}" \
|
||||
"platform-tools" \
|
||||
2>&1 | grep -v "^\[" || true
|
||||
fi
|
||||
|
||||
[ -d "$ANDROID_NDK_HOME" ] || err "NDK not found at $ANDROID_NDK_HOME"
|
||||
echo " NDK: $ANDROID_NDK_HOME"
|
||||
echo " SDK: $ANDROID_HOME"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4: Source code
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$CLONE" = "1" ]; then
|
||||
log "Cloning $REPO (branch: $BRANCH)"
|
||||
if [ -d "$WORKDIR/.git" ]; then
|
||||
cd "$WORKDIR"
|
||||
git fetch origin
|
||||
else
|
||||
rm -rf "$WORKDIR"
|
||||
git clone --branch "$BRANCH" --recurse-submodules "$REPO" "$WORKDIR"
|
||||
cd "$WORKDIR"
|
||||
fi
|
||||
git checkout "$BRANCH"
|
||||
git pull origin "$BRANCH" || true
|
||||
git submodule update --init --recursive
|
||||
|
||||
if [ -n "$COMMIT" ]; then
|
||||
log "Pinning to commit $COMMIT"
|
||||
git checkout "$COMMIT"
|
||||
fi
|
||||
else
|
||||
# Use current directory (assume we're in the repo root)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORKDIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$WORKDIR"
|
||||
[ -f "Cargo.toml" ] || err "Not in repo root. Run from repo root or set WZP_CLONE=1"
|
||||
fi
|
||||
|
||||
echo " HEAD: $(git log --oneline -1)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: Build native Rust library (.so)
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Building Rust native library (arm64-v8a, release)"
|
||||
cargo ndk -t arm64-v8a \
|
||||
-o "$WORKDIR/android/app/src/main/jniLibs" \
|
||||
build --release -p wzp-android
|
||||
|
||||
SO="$WORKDIR/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so"
|
||||
[ -f "$SO" ] || err ".so not found at $SO"
|
||||
echo " Built: $SO ($(du -h "$SO" | cut -f1))"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 6: Generate debug keystore (if missing)
|
||||
# ---------------------------------------------------------------------------
|
||||
KEYSTORE="$WORKDIR/android/keystore/wzp-debug.jks"
|
||||
if [ ! -f "$KEYSTORE" ]; then
|
||||
log "Generating debug keystore"
|
||||
mkdir -p "$(dirname "$KEYSTORE")"
|
||||
keytool -genkey -v \
|
||||
-keystore "$KEYSTORE" \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias wzp-debug \
|
||||
-storepass android -keypass android \
|
||||
-dname "CN=WZP Debug" 2>&1 | tail -1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 7: Build Android APK
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Building APK (debug)"
|
||||
cd "$WORKDIR/android"
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleDebug --no-daemon --warning-mode=none
|
||||
|
||||
APK=$(find . -name "app-debug*.apk" -path "*/outputs/apk/*" | head -1)
|
||||
[ -n "$APK" ] || err "APK not found"
|
||||
APK_ABS="$(cd "$(dirname "$APK")" && pwd)/$(basename "$APK")"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Done
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Build complete!"
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────────────────┐"
|
||||
echo " │ APK: $APK_ABS"
|
||||
echo " │ Size: $(du -h "$APK_ABS" | cut -f1)"
|
||||
echo " │ SHA256: $(sha256sum "$APK_ABS" | cut -d' ' -f1)"
|
||||
echo " └──────────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
echo " Install: adb install -r $APK_ABS"
|
||||
echo ""
|
||||
161
scripts/build-linux-docker.sh
Executable file
161
scripts/build-linux-docker.sh
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build WarzonePhone Linux x86_64 binaries via Docker on SepehrHomeserverdk.
|
||||
# Reuses same Docker image as Android build (has Rust + cmake + build tools).
|
||||
# Fire and forget — notifies via ntfy.sh/wzp with rustypaste URL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-linux-docker.sh Build + upload + notify
|
||||
# ./scripts/build-linux-docker.sh --pull Git pull before building
|
||||
# ./scripts/build-linux-docker.sh --clean Clean Rust target cache
|
||||
# ./scripts/build-linux-docker.sh --install Download binaries locally after build
|
||||
|
||||
REMOTE_HOST="SepehrHomeserverdk"
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/linux-x86_64"
|
||||
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
|
||||
|
||||
DO_PULL=0
|
||||
DO_CLEAN=0
|
||||
DO_INSTALL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--pull) DO_PULL=1 ;;
|
||||
--clean) DO_CLEAN=1 ;;
|
||||
--install) DO_INSTALL=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||
|
||||
ssh_cmd() { ssh $SSH_OPTS "$REMOTE_HOST" "$@"; }
|
||||
|
||||
# Upload build script to remote
|
||||
log "Uploading build script..."
|
||||
ssh_cmd "cat > /tmp/wzp-linux-build.sh" <<'REMOTE_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="/mnt/storage/manBuilder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
DO_PULL="${1:-0}"
|
||||
DO_CLEAN="${2:-0}"
|
||||
|
||||
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
trap 'notify "WZP Linux build FAILED! Check /tmp/wzp-linux-build.log"' ERR
|
||||
|
||||
if [ "$DO_PULL" = "1" ]; then
|
||||
echo ">>> Pulling latest..."
|
||||
cd "$BASE_DIR/data/source"
|
||||
git checkout -- . 2>/dev/null || true
|
||||
git pull origin feat/android-voip-client 2>&1 | tail -3
|
||||
fi
|
||||
|
||||
if [ "$DO_CLEAN" = "1" ]; then
|
||||
echo ">>> Cleaning Linux target cache..."
|
||||
rm -rf "$BASE_DIR/data/cache-linux/target"
|
||||
fi
|
||||
|
||||
# Ensure cache dirs exist (separate from Android cache)
|
||||
mkdir -p "$BASE_DIR/data/cache-linux/target" \
|
||||
"$BASE_DIR/data/cache-linux/cargo-registry" \
|
||||
"$BASE_DIR/data/cache-linux/cargo-git"
|
||||
|
||||
# Fix perms
|
||||
find "$BASE_DIR/data/source" "$BASE_DIR/data/cache-linux" \
|
||||
! -user 1000 -o ! -group 1000 2>/dev/null | \
|
||||
xargs -r chown 1000:1000 2>/dev/null || true
|
||||
|
||||
notify "WZP Linux x86_64 build started..."
|
||||
|
||||
echo ">>> Building in Docker..."
|
||||
docker run --rm --user 1000:1000 \
|
||||
-v "$BASE_DIR/data/source:/build/source" \
|
||||
-v "$BASE_DIR/data/cache-linux/cargo-registry:/home/builder/.cargo/registry" \
|
||||
-v "$BASE_DIR/data/cache-linux/cargo-git:/home/builder/.cargo/git" \
|
||||
-v "$BASE_DIR/data/cache-linux/target:/build/source/target" \
|
||||
wzp-android-builder bash -c '
|
||||
set -euo pipefail
|
||||
cd /build/source
|
||||
|
||||
echo ">>> Building relay + client + web + bench..."
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5
|
||||
|
||||
echo ">>> Building audio client..."
|
||||
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3
|
||||
cp target/release/wzp-client target/release/wzp-client-audio
|
||||
cargo build --release --bin wzp-client 2>&1 | tail -3
|
||||
|
||||
echo ">>> Binaries:"
|
||||
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench
|
||||
|
||||
echo ">>> Packaging..."
|
||||
tar czf /tmp/wzp-linux-x86_64.tar.gz \
|
||||
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench
|
||||
|
||||
echo "BINARIES_BUILT"
|
||||
'
|
||||
|
||||
# Upload to rustypaste
|
||||
echo ">>> Uploading to rustypaste..."
|
||||
source "$BASE_DIR/.env"
|
||||
TARBALL="$BASE_DIR/data/cache-linux/target/release/../../../wzp-linux-x86_64.tar.gz"
|
||||
# Docker wrote to /tmp inside container, copy from target mount
|
||||
docker run --rm \
|
||||
-v "$BASE_DIR/data/cache-linux/target:/build/target" \
|
||||
wzp-android-builder bash -c \
|
||||
"cp /build/target/release/wzp-relay /build/target/release/wzp-client /build/target/release/wzp-client-audio /build/target/release/wzp-web /build/target/release/wzp-bench /tmp/ && tar czf /tmp/wzp-linux-x86_64.tar.gz -C /tmp wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench && cat /tmp/wzp-linux-x86_64.tar.gz" \
|
||||
> /tmp/wzp-linux-x86_64.tar.gz
|
||||
|
||||
URL=$(curl -s -F "file=@/tmp/wzp-linux-x86_64.tar.gz" -H "Authorization: $rusty_auth_token" "$rusty_address")
|
||||
if [ -n "$URL" ]; then
|
||||
echo "UPLOAD_URL=$URL"
|
||||
notify "WZP Linux x86_64 binaries ready! $URL"
|
||||
echo ">>> Done! Binaries at: $URL"
|
||||
else
|
||||
notify "WZP Linux build FAILED - upload error"
|
||||
echo "ERROR: upload failed"
|
||||
exit 1
|
||||
fi
|
||||
REMOTE_SCRIPT
|
||||
|
||||
ssh_cmd "chmod +x /tmp/wzp-linux-build.sh"
|
||||
|
||||
# Run in tmux
|
||||
log "Starting Linux build in tmux..."
|
||||
ssh_cmd "tmux kill-session -t wzp-linux 2>/dev/null; true"
|
||||
ssh_cmd "tmux new-session -d -s wzp-linux '/tmp/wzp-linux-build.sh $DO_PULL $DO_CLEAN 2>&1 | tee /tmp/wzp-linux-build.log'"
|
||||
|
||||
log "Build running! Notification on ntfy.sh/wzp when done."
|
||||
echo ""
|
||||
echo " Monitor: ssh $REMOTE_HOST 'tail -f /tmp/wzp-linux-build.log'"
|
||||
echo " Status: ssh $REMOTE_HOST 'tail -5 /tmp/wzp-linux-build.log'"
|
||||
echo ""
|
||||
|
||||
# Optionally wait and download
|
||||
if [ "$DO_INSTALL" = "1" ]; then
|
||||
log "Waiting for build..."
|
||||
while true; do
|
||||
sleep 15
|
||||
if ssh_cmd "grep -q 'UPLOAD_URL\|ERROR' /tmp/wzp-linux-build.log 2>/dev/null"; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
URL=$(ssh_cmd "grep UPLOAD_URL /tmp/wzp-linux-build.log | tail -1 | cut -d= -f2")
|
||||
if [ -n "$URL" ]; then
|
||||
log "Downloading binaries..."
|
||||
mkdir -p "$LOCAL_OUTPUT"
|
||||
curl -s -o "$LOCAL_OUTPUT/wzp-linux-x86_64.tar.gz" "$URL"
|
||||
tar xzf "$LOCAL_OUTPUT/wzp-linux-x86_64.tar.gz" -C "$LOCAL_OUTPUT/"
|
||||
rm "$LOCAL_OUTPUT/wzp-linux-x86_64.tar.gz"
|
||||
ls -lh "$LOCAL_OUTPUT"/wzp-*
|
||||
log "Done! Binaries in $LOCAL_OUTPUT/"
|
||||
else
|
||||
err "Build failed"
|
||||
fi
|
||||
fi
|
||||
122
scripts/build-linux-notify.sh
Executable file
122
scripts/build-linux-notify.sh
Executable file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build WarzonePhone Linux x86_64 binaries via Hetzner Cloud VPS.
|
||||
# Fire and forget — notifies via ntfy.sh/wzp with rustypaste URL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-linux-notify.sh Full: create VM → build → upload → notify → destroy
|
||||
# ./scripts/build-linux-notify.sh --keep Keep VM after build
|
||||
# ./scripts/build-linux-notify.sh --pull Git pull (for existing VM)
|
||||
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx33"
|
||||
IMAGE="debian-12"
|
||||
SERVER_NAME="wzp-linux-builder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/linux-x86_64"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 -o ServerAliveInterval=15 -o LogLevel=ERROR"
|
||||
|
||||
KEEP_VM=0
|
||||
DO_PULL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep) KEEP_VM=1 ;;
|
||||
--pull) DO_PULL=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||
|
||||
get_vm_ip() {
|
||||
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || { err "No VM found"; exit 1; }
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip" "$@"
|
||||
}
|
||||
|
||||
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
# --- Create VM if needed ---
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -z "$existing" ]; then
|
||||
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create --name "$SERVER_NAME" --type "$SERVER_TYPE" --image "$IMAGE" --ssh-key "$SSH_KEY_NAME" --location fsn1 --quiet
|
||||
|
||||
log "Waiting for SSH..."
|
||||
ip=$(get_vm_ip)
|
||||
for i in $(seq 1 30); do
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip" "echo ok" &>/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "Installing deps..."
|
||||
ssh_cmd "apt-get update -qq && apt-get install -y -qq build-essential cmake pkg-config libasound2-dev libssl-dev curl git > /dev/null 2>&1"
|
||||
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
|
||||
fi
|
||||
|
||||
# --- Upload source ---
|
||||
log "Uploading source..."
|
||||
ip=$(get_vm_ip)
|
||||
rsync -az --delete \
|
||||
--exclude='target' --exclude='.git' --exclude='.claude' \
|
||||
--exclude='node_modules' --exclude='dist' --exclude='android/app/build' \
|
||||
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
|
||||
"$PROJECT_DIR/" "root@$ip:/root/wzp-build/"
|
||||
|
||||
# --- Build ---
|
||||
log "Building all binaries..."
|
||||
notify "WZP Linux build started..."
|
||||
|
||||
ssh_cmd "source ~/.cargo/env && cd /root/wzp-build && \
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5 && \
|
||||
echo '--- audio client ---' && \
|
||||
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3 && \
|
||||
cp target/release/wzp-client target/release/wzp-client-audio && \
|
||||
cargo build --release --bin wzp-client 2>&1 | tail -3 && \
|
||||
echo 'BUILD_DONE' && \
|
||||
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench"
|
||||
|
||||
# --- Package + upload to rustypaste ---
|
||||
log "Packaging and uploading..."
|
||||
UPLOAD_URL=$(ssh_cmd "cd /root/wzp-build && \
|
||||
tar czf /tmp/wzp-linux-x86_64.tar.gz \
|
||||
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench \
|
||||
-C /root/wzp-build/crates/wzp-web/static index.html audio-processor.js 2>/dev/null && \
|
||||
curl -s -F 'file=@/tmp/wzp-linux-x86_64.tar.gz' \
|
||||
-H 'Authorization: DAxAAGghkn1WKv1+RpPKkg==' \
|
||||
https://paste.dk.manko.yoga")
|
||||
|
||||
if [ -n "$UPLOAD_URL" ]; then
|
||||
notify "WZP Linux binaries ready! $UPLOAD_URL"
|
||||
log "Uploaded: $UPLOAD_URL"
|
||||
else
|
||||
notify "WZP Linux build FAILED"
|
||||
err "Upload failed"
|
||||
fi
|
||||
|
||||
# --- Transfer locally ---
|
||||
log "Downloading binaries..."
|
||||
mkdir -p "$LOCAL_OUTPUT"
|
||||
for bin in wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench; do
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip:/root/wzp-build/target/release/$bin" "$LOCAL_OUTPUT/$bin" 2>/dev/null
|
||||
done
|
||||
ls -lh "$LOCAL_OUTPUT"/wzp-*
|
||||
|
||||
# --- Cleanup ---
|
||||
if [ "$KEEP_VM" = "1" ]; then
|
||||
log "VM kept alive. Destroy: hcloud server delete $SERVER_NAME"
|
||||
else
|
||||
log "Destroying VM..."
|
||||
hcloud server delete "$SERVER_NAME"
|
||||
fi
|
||||
|
||||
log "Done!"
|
||||
echo " Deploy: scp $LOCAL_OUTPUT/wzp-relay user@server:~/wzp/"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user