Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0060f3c19 | ||
|
|
8cb5a7a258 | ||
|
|
484dad1072 | ||
|
|
d3bb199208 | ||
|
|
2f47e7ccee | ||
|
|
29afbb5e97 | ||
|
|
808324f374 | ||
|
|
2c3c3873dc | ||
|
|
d886e285d1 | ||
|
|
0381be6964 | ||
|
|
5f7e2f7e97 | ||
|
|
dab90852a3 | ||
|
|
1813bbaa0c | ||
|
|
da174806ac | ||
|
|
80c2ed46bc | ||
|
|
7510bdca73 | ||
|
|
ddf858cc6c | ||
|
|
966afd2b53 | ||
|
|
96aa943d5f | ||
|
|
0e6c4941ad | ||
|
|
1fc0aaeabb | ||
|
|
d19501ec66 | ||
|
|
92c09aece3 | ||
|
|
8506e56da1 | ||
|
|
31373d3ec4 | ||
|
|
053c248ec4 | ||
|
|
7d47a1e14b | ||
|
|
112a563de5 | ||
|
|
cac178f4af | ||
|
|
813a38e92b | ||
|
|
71b7502ab7 | ||
|
|
25e6a16eb1 | ||
|
|
7fb89c3dc7 | ||
|
|
a4f6d66aec | ||
|
|
714a6b758c | ||
|
|
ecb9ae9636 | ||
|
|
e8e9fa8ccf | ||
|
|
6f7063971d | ||
|
|
c02d4e6df3 | ||
|
|
d39d6c78d2 | ||
|
|
681f3b636f | ||
|
|
b320f24c93 | ||
|
|
0e04dac064 | ||
|
|
fe6e07353e | ||
|
|
2896f14ec9 | ||
|
|
487aff4e7c | ||
|
|
d03ea937e6 | ||
|
|
cd14aa7b98 | ||
|
|
a2d686d9d0 | ||
|
|
c19de3a370 | ||
|
|
c502a4dd00 | ||
|
|
afd8e525f9 | ||
|
|
862f83b297 | ||
|
|
4a2a05ef83 | ||
|
|
b8df25a195 | ||
|
|
5c8c6df16b | ||
|
|
1709ae2ac2 | ||
|
|
0648203100 | ||
|
|
6f64062fd8 | ||
|
|
7c9660572e | ||
|
|
1326b03301 | ||
|
|
5515ff6643 | ||
|
|
eb9014cd81 | ||
|
|
d7556cd39f | ||
|
|
11d4e7b7da | ||
|
|
b8195acc9b | ||
|
|
b494f8902d | ||
|
|
9b8d812f9c | ||
|
|
3200312b0d | ||
|
|
7d5d8c2c90 | ||
|
|
7e28791ec2 | ||
|
|
738b274748 | ||
|
|
bf284018e6 | ||
|
|
6af4ecab0f | ||
|
|
171baf6e41 | ||
|
|
9a2c882ab0 | ||
|
|
f8a9c7a422 | ||
|
|
f4db27b565 | ||
|
|
2382c2b569 | ||
|
|
256874997b | ||
|
|
b65cedfa95 | ||
|
|
bc1bd89424 | ||
|
|
b21fc16ee6 | ||
|
|
34121b2eb7 | ||
|
|
48a38dba46 | ||
|
|
f8b6485390 | ||
|
|
273eaf21c1 | ||
|
|
42d0ca0953 | ||
|
|
bc70da51df | ||
|
|
7473878136 | ||
|
|
3ae8e1ab77 | ||
|
|
30ff955439 | ||
|
|
eb1f4b895e | ||
|
|
210a6a0e11 | ||
|
|
7254f81c65 | ||
|
|
2861f5b010 | ||
|
|
26b06ce786 | ||
|
|
d6763787aa | ||
|
|
c87b47f064 | ||
|
|
7d6d56e330 | ||
|
|
8c253f1d4d | ||
|
|
29a553d4e1 | ||
|
|
477394d83e | ||
|
|
167aef5760 | ||
|
|
a77a9b2ef2 | ||
|
|
92087d066a | ||
|
|
ed3c01bcb9 | ||
|
|
b4cfb8174d | ||
|
|
7188a5a69d | ||
|
|
575a6cbc5c | ||
|
|
d13b56d379 | ||
|
|
5aa51c310f | ||
|
|
4c19410556 | ||
|
|
c4145a8727 | ||
|
|
0dc8297568 | ||
|
|
7966a7c8c6 | ||
|
|
56222d190e | ||
|
|
78190c0694 | ||
|
|
76c6d1d40c | ||
|
|
da0b208075 | ||
|
|
dc9c5c0a4e | ||
|
|
00692b76a2 | ||
|
|
72c6e14797 | ||
|
|
63b102ed27 | ||
|
|
4ecea09bbc | ||
|
|
b97cc9589b | ||
|
|
618b6930fe | ||
|
|
7a59f5b9a1 | ||
|
|
a1fefc8ab6 | ||
|
|
a9aa19ecdd | ||
|
|
99584fbc1a | ||
|
|
53e09ea091 | ||
|
|
2735c55c97 | ||
|
|
6e0bf21295 | ||
|
|
e85a6d36d7 | ||
|
|
c16318c86b | ||
|
|
c89acacc74 | ||
|
|
63cbab901e | ||
|
|
ecf9370db1 | ||
|
|
e7c3ffa6d2 | ||
|
|
1aa514df75 | ||
|
|
9502580b5a | ||
|
|
5de6eccc0c | ||
|
|
97a6ba6b55 | ||
|
|
3b1724e970 | ||
|
|
af686ca7a6 | ||
|
|
6a4dbad60e | ||
|
|
2f6cdd3ad4 | ||
|
|
da631a2bef | ||
|
|
b1d117033e | ||
|
|
9413703372 | ||
|
|
54ec8dd5a8 | ||
|
|
be160f5edd | ||
|
|
f0016155aa | ||
|
|
0c1fd1c2e3 | ||
|
|
6955547cec | ||
|
|
1ceb47f1e3 | ||
|
|
7c62d14dd8 | ||
|
|
79f83604da | ||
|
|
23bd1c7930 | ||
|
|
b8e9c8549e | ||
|
|
fa94b9438f | ||
|
|
4ee2b3850a | ||
|
|
0466000733 | ||
|
|
020be77faf | ||
|
|
a93109ac37 | ||
|
|
f1f81a3d7e | ||
|
|
3b08ea9d32 | ||
|
|
7c0e5caa92 | ||
|
|
d4c04b6a72 | ||
|
|
278df25fd7 | ||
|
|
f5d3810f5f | ||
|
|
1f729284b3 | ||
|
|
6d094f3dbe | ||
|
|
f0ddb160a0 | ||
|
|
41d5a4b91b | ||
|
|
f3320715ea | ||
|
|
383b707e7c | ||
|
|
e3d7017dd9 | ||
|
|
ade50488d9 | ||
|
|
4d57d189c9 | ||
|
|
cd402f164a | ||
|
|
25b5bb3a6d | ||
|
|
d9c330c23b | ||
|
|
8acd456af1 | ||
|
|
73066749e3 | ||
|
|
6df5ea960e | ||
|
|
61798e5bdf | ||
|
|
01003b3448 | ||
|
|
2511b24982 | ||
|
|
0b6ea11077 | ||
|
|
881321595e | ||
|
|
606d594dc9 | ||
|
|
181b3368d4 | ||
|
|
bb3c82e2d5 | ||
|
|
45fec2310e | ||
|
|
6d4975bd6e | ||
|
|
21945979a3 | ||
|
|
7596f8a9dc | ||
|
|
87cff52c0a | ||
|
|
82b99ebcff | ||
|
|
33d80894d0 | ||
|
|
64878e3a7d | ||
|
|
8cd919cffc | ||
|
|
6a41a0b4db | ||
|
|
7f4874f4c0 | ||
|
|
6a077ac7a1 | ||
|
|
ba444d94bd | ||
|
|
dfa84ee7bb | ||
|
|
f9d20d8585 | ||
|
|
77c2a1def4 | ||
|
|
8e04a1e2a6 | ||
|
|
5c413ba199 | ||
|
|
feceacfdad | ||
|
|
9831624079 | ||
|
|
63f2901a6a | ||
|
|
03f5d6bca3 | ||
|
|
82d012a46b | ||
|
|
5199e3d005 | ||
|
|
7b21916049 | ||
|
|
02ffadfa52 | ||
|
|
b932dc1405 | ||
|
|
50768a8f48 | ||
|
|
780fd7eca2 | ||
|
|
fd181c014c | ||
|
|
43a3c9ba76 | ||
|
|
d2d9ba0a5b | ||
|
|
71ecb83781 | ||
|
|
f52d702e27 | ||
|
|
0bef61aea2 | ||
|
|
bc0da50cdb | ||
|
|
1324f478fe | ||
|
|
c81ccfd9a9 | ||
|
|
8094ec7277 | ||
|
|
9790005c3e | ||
|
|
53b176b722 |
@@ -0,0 +1,34 @@
|
|||||||
|
# Environment variables set for all cargo invocations in this workspace.
|
||||||
|
# CMAKE_POLICY_VERSION_MINIMUM is required for audiopus_sys's bundled
|
||||||
|
# Opus CMake build to succeed on CMake 4.x (which removed compatibility
|
||||||
|
# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles
|
||||||
|
# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version.
|
||||||
|
[env]
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
|
||||||
|
# iOS builds must set IPHONEOS_DEPLOYMENT_TARGET in the invoking script
|
||||||
|
# or Xcode build phase. Do not set it globally here: native macOS cargo
|
||||||
|
# checks also compile bundled C/C++ dependencies, and a global iOS
|
||||||
|
# deployment target makes clang try to link iPhone objects against the
|
||||||
|
# macOS SDK.
|
||||||
|
|
||||||
|
# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0).
|
||||||
|
#
|
||||||
|
# These rustflags pass -miphoneos-version-min=13.0 to the linker, ensuring
|
||||||
|
# the final binary targets iOS 13.0+. This is defense-in-depth alongside
|
||||||
|
# the IPHONEOS_DEPLOYMENT_TARGET env var above — the env var affects C
|
||||||
|
# compilation (cc crate, CMake), while these rustflags affect the final
|
||||||
|
# link step.
|
||||||
|
#
|
||||||
|
# NOTE: The canonical iOS build is done via tools/build-ios.sh, which
|
||||||
|
# sets LIBOPUS_STATIC=1, LIBOPUS_NO_PKG=1, and LIBOPUS_LIB_DIR to
|
||||||
|
# bypass audiopus_sys's CMake build entirely.
|
||||||
|
|
||||||
|
[target.aarch64-apple-ios]
|
||||||
|
rustflags = ["-C", "link-arg=-miphoneos-version-min=13.0"]
|
||||||
|
|
||||||
|
[target.aarch64-apple-ios-sim]
|
||||||
|
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
||||||
|
|
||||||
|
[target.x86_64-apple-ios]
|
||||||
|
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
name: bench-advisory
|
||||||
|
|
||||||
|
# SDD-120 §6 / SRS-218 — advisory-only realtime-audio bench workflow.
|
||||||
|
# Runs the criterion bench harness on PR + tag events, compares the
|
||||||
|
# current results against the SAD-089 baseline JSON resolved at the
|
||||||
|
# merge-base, and renders a markdown report posted (or updated) as a
|
||||||
|
# single sticky PR comment. The job status is ALWAYS success — this
|
||||||
|
# workflow never fails a check on regression (SRS-218 clause 4).
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
push:
|
||||||
|
tags: ["**"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bench-advisory:
|
||||||
|
name: bench-advisory
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: System deps (cpal / Opus / SDL2)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev libpulse-dev pkg-config \
|
||||||
|
libdbus-1-dev \
|
||||||
|
libsdl2-dev \
|
||||||
|
libopus-dev
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Run benchmarks
|
||||||
|
run: |
|
||||||
|
if ! cargo bench -p chanora_audio \
|
||||||
|
--bench realtime_capture \
|
||||||
|
--bench opus_codec \
|
||||||
|
--bench resampler; then
|
||||||
|
echo "::warning::Benchmark harness failed; continuing advisory workflow per SRS-218 clause 4"
|
||||||
|
fi
|
||||||
|
- name: Emit current baseline JSON
|
||||||
|
run: cargo run --example emit_baseline -p chanora_audio
|
||||||
|
- name: Resolve merge-base baseline
|
||||||
|
run: |
|
||||||
|
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||||
|
MERGE_BASE=$(git merge-base "$BASE_SHA" HEAD || true)
|
||||||
|
else
|
||||||
|
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD || true)
|
||||||
|
fi
|
||||||
|
if [ -n "$MERGE_BASE" ] && git show "${MERGE_BASE}:crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json" > baseline.json 2>/dev/null; then
|
||||||
|
echo "BASELINE_FOUND=1" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "BASELINE_FOUND=0" >> "$GITHUB_ENV"
|
||||||
|
echo "{}" > baseline.json
|
||||||
|
fi
|
||||||
|
- name: Compare against baseline
|
||||||
|
run: |
|
||||||
|
cargo run --example compare_baseline -p chanora_audio -- \
|
||||||
|
--current current.json \
|
||||||
|
--baseline baseline.json \
|
||||||
|
--output report.md
|
||||||
|
- name: Post PR comment
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const body = fs.readFileSync('report.md', 'utf8');
|
||||||
|
const marker = '<!-- chanora-bench-advisory -->';
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.startsWith(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
- name: Upload report artifact (push events)
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bench-report
|
||||||
|
path: report.md
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: bench-baseline-update
|
||||||
|
|
||||||
|
# SDD-120 §7 / SAD-089 — manual-dispatch workflow that opens a PR
|
||||||
|
# updating the committed baseline JSON. This is the SOLE writer of
|
||||||
|
# `crates/chanora_audio/benches/baselines/<host>.json`. Triggered
|
||||||
|
# only on operator demand via workflow_dispatch.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bench-baseline-update:
|
||||||
|
name: bench-baseline-update
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: System deps (cpal / Opus)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev libpulse-dev pkg-config \
|
||||||
|
libopus-dev
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Run benchmarks
|
||||||
|
run: |
|
||||||
|
cargo bench -p chanora_audio \
|
||||||
|
--bench realtime_capture \
|
||||||
|
--bench opus_codec \
|
||||||
|
--bench resampler
|
||||||
|
- name: Emit current baseline JSON
|
||||||
|
run: cargo run --example emit_baseline -p chanora_audio
|
||||||
|
- name: Copy current to baseline path
|
||||||
|
run: cp current.json crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
branch: bench/baseline-update-${{ github.run_id }}
|
||||||
|
base: product/scaffold-v0
|
||||||
|
title: "chore(bench): update realtime audio baselines"
|
||||||
|
body: |
|
||||||
|
Automated baseline update produced by manual dispatch of
|
||||||
|
`bench-baseline-update.yml`.
|
||||||
|
|
||||||
|
Run: ${{ github.run_id }}
|
||||||
|
Triggered by: ${{ github.actor }}
|
||||||
|
commit-message: "chore(bench): update realtime audio baselines"
|
||||||
|
add-paths: |
|
||||||
|
crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
name: cargo check + cargo test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: System deps (cpal / Opus / SQLite / SDL2)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev libpulse-dev pkg-config \
|
||||||
|
libdbus-1-dev \
|
||||||
|
libsdl2-dev \
|
||||||
|
libopus-dev
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace --locked
|
||||||
|
- name: cargo test --workspace
|
||||||
|
env:
|
||||||
|
# Storage tests must not hit the real OS keyring on CI:
|
||||||
|
# there is no D-Bus session available and the call would
|
||||||
|
# block. The runtime code carries the same toggle for
|
||||||
|
# headless / sandboxed environments.
|
||||||
|
CHANORA_DISABLE_KEYRING: "1"
|
||||||
|
run: cargo test --workspace --locked --no-fail-fast
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
supply-chain:
|
||||||
|
name: cargo deny (licenses + advisories + bans + sources)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||||
|
with:
|
||||||
|
command: check
|
||||||
|
# `licenses` enforces the DEC-020 license posture; the
|
||||||
|
# other three are minimal supply-chain hygiene per
|
||||||
|
# `docs/governance/legal-review-readiness.md` §5.
|
||||||
|
arguments: --workspace --all-features
|
||||||
|
|
||||||
|
license-inventory:
|
||||||
|
name: cargo about (license inventory)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install cargo-about
|
||||||
|
run: cargo install --locked --features cli cargo-about
|
||||||
|
- name: Regenerate inventory and compare
|
||||||
|
# Build the inventory in a temp file and diff against the
|
||||||
|
# committed copy. CI fails when the committed inventory is
|
||||||
|
# stale, forcing contributors to run the tool locally
|
||||||
|
# before opening a PR that touches the dependency tree.
|
||||||
|
run: |
|
||||||
|
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
|
||||||
|
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|
||||||
|
|| { echo "::error::docs/security/license-inventory.md is stale; regenerate with 'cargo about generate --output-file docs/security/license-inventory.md about-md.hbs'"; exit 1; }
|
||||||
|
|
||||||
|
flutter-license-inventory:
|
||||||
|
name: flutter license inventory
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
- name: flutter pub get
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Regenerate Flutter license inventory and compare
|
||||||
|
env:
|
||||||
|
# Resolved by the wrapper from $HOME/sdks/flutter when
|
||||||
|
# not set; CI's subosito/flutter-action puts flutter on
|
||||||
|
# PATH but exports the SDK root under FLUTTER_ROOT.
|
||||||
|
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
|
||||||
|
run: |
|
||||||
|
./tools/dump_flutter_licenses.sh
|
||||||
|
if ! git diff --quiet docs/security/flutter-license-inventory.md; then
|
||||||
|
echo "::error::docs/security/flutter-license-inventory.md is stale; regenerate with 'tools/dump_flutter_licenses.sh'"
|
||||||
|
git --no-pager diff docs/security/flutter-license-inventory.md | head -40
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
name: flutter analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
- name: flutter pub get
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter pub get
|
||||||
|
- name: flutter analyze
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter analyze
|
||||||
|
- name: flutter test (unit only)
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter test --exclude-tags e2e
|
||||||
|
|
||||||
|
flutter-ios-release-build:
|
||||||
|
name: flutter iOS unsigned release build
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
- name: Try to fetch local SileroCoreML package
|
||||||
|
run: git submodule update --init --depth=1 silero-coreml || true
|
||||||
|
- name: flutter pub get
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Check local SileroCoreML package
|
||||||
|
id: silero-coreml
|
||||||
|
run: |
|
||||||
|
if [ -f silero-coreml/Package.swift ]; then
|
||||||
|
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "::notice::Skipping iOS build because silero-coreml submodule is not available"
|
||||||
|
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: flutter build ios --no-codesign
|
||||||
|
if: steps.silero-coreml.outputs.available == 'true'
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter build ios --release --no-codesign
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: opencode
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
opencode:
|
||||||
|
if: |
|
||||||
|
contains(github.event.comment.body, ' /oc') ||
|
||||||
|
startsWith(github.event.comment.body, '/oc') ||
|
||||||
|
contains(github.event.comment.body, ' /opencode') ||
|
||||||
|
startsWith(github.event.comment.body, '/opencode')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run opencode
|
||||||
|
uses: anomalyco/opencode/github@latest
|
||||||
|
env:
|
||||||
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
|
with:
|
||||||
|
model: zhipuai-coding-plan/glm-5.1
|
||||||
@@ -43,6 +43,13 @@ android/app/build/
|
|||||||
reports/
|
reports/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# SDD-120 local bench-harness outputs (cargo run --example emit_baseline /
|
||||||
|
# compare_baseline at the workspace root). Real baseline JSON is committed
|
||||||
|
# at crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
|
||||||
|
# via the bench-baseline-update.yml workflow only.
|
||||||
|
/current.json
|
||||||
|
/report.md
|
||||||
|
|
||||||
# Diagnostic bundles
|
# Diagnostic bundles
|
||||||
diagnostics/
|
diagnostics/
|
||||||
*.diag.zip
|
*.diag.zip
|
||||||
@@ -89,3 +96,27 @@ chanora-docs-repo-format-*.zip
|
|||||||
|
|
||||||
# IntelliJ / Android Studio per-project
|
# IntelliJ / Android Studio per-project
|
||||||
**/*.iml
|
**/*.iml
|
||||||
|
|
||||||
|
# Eclipse / Buildship local project metadata
|
||||||
|
**/.project
|
||||||
|
**/.classpath
|
||||||
|
**/.settings/
|
||||||
|
|
||||||
|
# Flutter DevTools local preferences
|
||||||
|
**/devtools_options.yaml
|
||||||
|
|
||||||
|
# gen2/ holds the externally-provided review summary + zip baseline.
|
||||||
|
# Not part of the repo tree; do not commit.
|
||||||
|
/gen2/
|
||||||
|
|
||||||
|
# opencode agent local config
|
||||||
|
opencode.json
|
||||||
|
|
||||||
|
# Local protocol scratch file
|
||||||
|
/test_tsproto.rs
|
||||||
|
|
||||||
|
# iOS framework build artifacts produced by chanora_bridge.podspec
|
||||||
|
/apps/chanora_flutter/ios/Frameworks/
|
||||||
|
.opencode/
|
||||||
|
AGENTS.md
|
||||||
|
Screenshot 2026-05-17 at 22.23.07.png
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "silero-coreml"]
|
||||||
|
path = silero-coreml
|
||||||
|
url = git@github.com:chanoraapp/silero-coreml.git
|
||||||
@@ -2,212 +2,133 @@
|
|||||||
|
|
||||||
All notable changes to Chanora will be documented in this file.
|
All notable changes to Chanora will be documented in this file.
|
||||||
|
|
||||||
This project is expected to follow a Conventional Commits style workflow.
|
This project follows a Conventional Commits style workflow.
|
||||||
|
|
||||||
## [Unreleased]
|
## [v0.3.0] — Cross-platform voice client baseline
|
||||||
|
|
||||||
### Added — Alpha build (v0.1.0-alpha.1)
|
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||||
|
prototype to a cross-platform baseline client with event-driven UI,
|
||||||
|
per-user audio controls, non-self client info parity, and CI-hardened
|
||||||
|
Android / iOS / macOS / Linux builds.
|
||||||
|
|
||||||
- **First Alpha build wires the connect → snapshot → disconnect cycle
|
### Added
|
||||||
end-to-end from the Flutter UI to a live TeamSpeak-compatible
|
|
||||||
server via the typed Flutter/Rust bridge.** Per DEC-001 this is the
|
- **Event-driven UI** replaces timer-based snapshot polling. Protocol
|
||||||
Internal Alpha milestone; Audio (voice in/out) is deferred to Beta.
|
deltas (client join/leave/move/update, channel add/remove/update)
|
||||||
- `crates/chanora_protocol/` promoted from a scaffold to a working
|
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||||
adapter. Public surface:
|
in real time. Channel switching is instant.
|
||||||
- `ConnectConfig`, `ProtocolClient`, `ProtocolError`.
|
- **Per-user volume controls.** Each client in the snapshot gets an
|
||||||
- DTO module exposing `ChannelId`, `ClientId`, `ChannelInfo`,
|
independent volume slider persisted in the bridge layer. Avatar
|
||||||
`ClientInfo`, `ServerSnapshot` — all owned primitives and
|
badges show muted/deafened state. Volume adjustments take effect
|
||||||
`String`s; no `tsclientlib::*` types leak (SAD-067).
|
immediately on the audio mix.
|
||||||
- Tokio task owns the `tsclientlib::Connection`; public handle
|
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||||
communicates via `mpsc` requests + `oneshot` replies.
|
connection metadata (name, description, created, last connected,
|
||||||
- Connect waits for the initial `BookEvents` snapshot, then pumps
|
connections, transfer, ping deviation) for other clients via an
|
||||||
events for ~2 s so the subscribed channel tree settles before
|
explicit `ClientProfileRefreshPlan` that fetches `connectioninfo`
|
||||||
the first snapshot is served.
|
and recent text messages with a bounded timeout.
|
||||||
- Promoted from `poc/tsclientlib-connect-spike`.
|
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||||
- `core/chanora_core::ChanoraSession` now drives the protocol crate
|
propagated from protocol DTO through bridge API to Dart, with a
|
||||||
with a typed `connect`/`snapshot`/`is_connected`/`disconnect` API.
|
conditional l10n row in the client info sheet (en + zh).
|
||||||
Enforces the DEC-006 single-connection invariant via an internal
|
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
||||||
`tokio::sync::Mutex<Option<ProtocolClient>>`.
|
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
||||||
- `crates/chanora_bridge/` wired against `flutter_rust_bridge` 2.12.0
|
available. WebRTC VAD remains the runtime fallback.
|
||||||
(DEC-014). Compiled as `cdylib + staticlib + rlib`. Exposes:
|
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||||
- `bridge_init()` (FRB lifecycle), `connect()`, `snapshot()`,
|
lookups and `ts3server://` URI handling.
|
||||||
`disconnect()`, `is_connected()`.
|
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||||
- Typed `BridgeChannel`, `BridgeClient`, `BridgeSnapshot` DTOs;
|
--split-per-abi` produces separate arm64-v8a / armeabi-v7a / x86_64
|
||||||
`BridgeError` with `From<chanora_core::CoreError>`.
|
APKs instead of a single fat APK.
|
||||||
- A process-wide `tokio::Runtime` + `ChanoraSession` via
|
- **Cross-platform CI** (GitHub Actions): cargo check + test, cargo
|
||||||
`OnceLock`, used by every async command.
|
deny, cargo about license inventory, flutter analyze, flutter test,
|
||||||
- The crate's `#![forbid(unsafe_code)]` lint was lifted to
|
flutter iOS unsigned release build. All gates must pass before merge.
|
||||||
`#![warn(missing_docs)]` only, with a doc-comment explanation
|
- **Diagnostic recording.** Bridge events, audio interruptions, and
|
||||||
that the FRB-generated glue legitimately uses unsafe at the FFI
|
Rust-level log entries are captured into an in-memory ring buffer
|
||||||
boundary; hand-written code in the crate is still expected to
|
accessible from the diagnostics page.
|
||||||
avoid `unsafe`.
|
- **Shared app snackbar styling** for consistent error/info feedback.
|
||||||
- `flutter_rust_bridge.yaml` at the repo root drives codegen for the
|
|
||||||
bridge.
|
|
||||||
- Generated Dart bindings under
|
|
||||||
`apps/chanora_flutter/lib/src/rust/{api.dart,frb_generated*.dart,lib*.dart}`.
|
|
||||||
- Generated Rust glue under `crates/chanora_bridge/src/frb_generated.rs`.
|
|
||||||
- `apps/chanora_flutter/lib/main.dart` rewritten as the Alpha UI:
|
|
||||||
- Form: server address + nickname, both pre-populated for
|
|
||||||
convenience.
|
|
||||||
- Connect button → calls FRB → enters connecting state → shows
|
|
||||||
snapshot.
|
|
||||||
- Snapshot view: server welcome banner (preserved verbatim per
|
|
||||||
ADR-008), `N channels • M online` count, ordered channel list
|
|
||||||
with clients indented under their channel.
|
|
||||||
- Refresh and Disconnect actions in the app bar.
|
|
||||||
- `apps/chanora_flutter/lib/l10n/app_{en,zh}.arb` expanded with the
|
|
||||||
Alpha key set:
|
|
||||||
`homeNotProductionReadyBanner` (now says "Alpha build"),
|
|
||||||
`fieldServerHost`, `fieldNickname`,
|
|
||||||
`connectAction`, `disconnectAction`, `refreshAction`,
|
|
||||||
`statusIdle`, `statusConnecting`, `statusConnected`, `statusError`,
|
|
||||||
`channelsHeading`, `clientsHeading`, `countChannelsAndClients`.
|
|
||||||
- `flutter_localizations`, `intl`, `flutter_rust_bridge`,
|
|
||||||
`freezed_annotation` added to dependencies;
|
|
||||||
`freezed` and `build_runner` added to dev_dependencies.
|
|
||||||
- `apps/chanora_flutter/test/alpha_e2e_test.dart` runs the full
|
|
||||||
Dart → FRB → Rust → tsclientlib → network → server path against
|
|
||||||
`cn.teamspeak.app`. Verifies the snapshot contains a non-empty
|
|
||||||
server name and a non-empty channel list, that `isConnected()`
|
|
||||||
flips true → false across the disconnect, and that a re-fetched
|
|
||||||
snapshot agrees on the server name. Passes in ~2.5 s.
|
|
||||||
- `core/chanora_core/tests/alpha_smoke.rs` runs the same path from the
|
|
||||||
Rust side; tagged `#[ignore]` so `cargo test --workspace` doesn't
|
|
||||||
hit the network by default. Run with `--ignored alpha_smoke`.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- `chanora_core::CoreError` no longer wraps `chanora_bridge::BridgeError`;
|
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||||
the relationship is the other way around (bridge maps from core).
|
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||||
This removes a cyclic `chanora_core` ↔ `chanora_bridge` dependency
|
audio startup.
|
||||||
introduced when the bridge crate gained `chanora_core` as a dep.
|
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
||||||
- `chanora_bridge` lints relaxed from `#![forbid(unsafe_code)]` to
|
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
||||||
`#![warn(missing_docs)]` (documented above).
|
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||||
|
startup permission flow, and system back-button integration.
|
||||||
### LICENSE files (carry-over from earlier in this branch)
|
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||||
|
stream. The dead variant was removed end-to-end (protocol, bridge,
|
||||||
- `LICENSE-APACHE` — Apache License Version 2.0 text (DEC-020).
|
Flutter).
|
||||||
- `LICENSE-MIT` — MIT License text (DEC-020).
|
- **Prefetch crate renamed** from the PoC-era name to
|
||||||
- Initial repository foundation files.
|
`chanora_prefetch`. All docs, specs, and code updated.
|
||||||
- Documentation-first project structure.
|
- **Build number bumped to 76.**
|
||||||
- `justfile` with `format`, `lint`, `test`, `verify-docs`, and
|
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||||
`security-scan` targets, completing `repository-bootstrap-plan` v0.1.0 §3.
|
|
||||||
- `poc/tsclientlib-connect-spike/` — PoC proving protocol feasibility via
|
|
||||||
`tsclientlib`. Verified against `cn.teamspeak.app` on 2026-05-13.
|
|
||||||
- `poc/flutter_rust_bridge_hello/` — PoC proving the Flutter↔Rust command
|
|
||||||
and event-stream boundary via `flutter_rust_bridge` 2.12.0. Verified on
|
|
||||||
Linux desktop on 2026-05-13.
|
|
||||||
- `poc/secure-storage-spike/` — PoC proving platform secure storage via
|
|
||||||
a typed `SecretStorageRepository` trait and a Linux adapter selecting
|
|
||||||
between Secret Service (libsecret) and kernel keyutils. Audit checks
|
|
||||||
SS-AUD-001/002/003/005/006 and SS-TC-003 verified on 2026-05-13.
|
|
||||||
- `poc/sqlite-storage-spike/` — PoC proving SRS-089's "embedded data
|
|
||||||
store + migration mechanism" acceptance criteria: forward-only schema
|
|
||||||
migrator tracked via `PRAGMA user_version`, repository pattern with
|
|
||||||
`BookmarkRepository` / `SettingsRepository` traits over
|
|
||||||
`LocalDatabaseRepository`. 11/11 tests verified on 2026-05-13.
|
|
||||||
- `poc/diagnostics-redaction-spike/` — PoC proving the diagnostic
|
|
||||||
redaction policy from `diagnostic-redaction-audit-report.md`:
|
|
||||||
typed policy + regex rules + literal known-secret registry + bundle
|
|
||||||
redaction. Audit cases REDACT-TC-001..010 verified on 2026-05-13.
|
|
||||||
- `poc/audio-capture-playback-spike/` — PoC proving platform audio
|
|
||||||
capture/playback via cpal. Desktop half (Linux + PipeWire)
|
|
||||||
empirically verified end-to-end on 2026-05-13; mobile half closed
|
|
||||||
separately by `poc/audio-capture-playback-android-spike`.
|
|
||||||
- `poc/audio-capture-playback-android-spike/` — PoC closing the
|
|
||||||
mobile half of the audio capture/playback PoC plan entry. Rust
|
|
||||||
cdylib + JNI + Kotlin Android app; cpal targets Android's Oboe
|
|
||||||
backend (AAudio). Verified end-to-end on a physical Motorola
|
|
||||||
Moto G Stylus 5G (2023) running Android 14 arm64-v8a on
|
|
||||||
2026-05-13: 500 ms 440 Hz sine wave driven out the device speaker
|
|
||||||
(22,050 frames at 44.1 kHz) and 1 s captured from the microphone
|
|
||||||
into a valid 85,292-byte RIFF/WAVE mono 16-bit PCM file pulled via
|
|
||||||
`adb exec-out run-as`.
|
|
||||||
- `poc/README.md` summarising PoC status against
|
|
||||||
`docs/architecture/proof-of-concept-plan.md`.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **DEC-020 license resolved.** Chanora is now dual-licensed under
|
|
||||||
**Apache-2.0 OR MIT** (recipient's choice), the standard
|
|
||||||
Rust-ecosystem permissive model. Compatible with every direct
|
|
||||||
dependency (`tsclientlib`, `flutter_rust_bridge`, `cpal`, `rusqlite`,
|
|
||||||
`keyring`, etc.) and with the Flutter framework's BSD-3-Clause.
|
|
||||||
`LICENSE` rewritten as a dual-license aggregator pointing at
|
|
||||||
`LICENSE-APACHE` and `LICENSE-MIT`. `NOTICE` rewritten with current
|
|
||||||
direct-dependency attributions. `README.md` §License updated.
|
|
||||||
- `docs/governance/product-decision-register.md` bumped to v0.9.6:
|
|
||||||
DEC-020 status promoted from Open to Accepted. §4 license row
|
|
||||||
updated. §6 collapsed: there is no longer any open decision —
|
|
||||||
DEC-012 legal review remains as a *work* item, not a pending
|
|
||||||
decision. Change-history entry added.
|
|
||||||
- `docs/governance/poc-results-summary.md` bumped to v0.4.0:
|
|
||||||
RISK-PoC-003 closed. DEC-020 row moved from the "Still open"
|
|
||||||
section into the closed table.
|
|
||||||
|
|
||||||
- `docs/governance/product-decision-register.md` bumped to v0.9.5:
|
|
||||||
owner confirmation on all 17 previously-Proposed decisions
|
|
||||||
(DEC-001..010, 012, 015..019, 021). Sixteen were Accepted as
|
|
||||||
recommended; two were modified by the owner — **DEC-004**
|
|
||||||
Android minimum raised from API 24 to **API 28**, and
|
|
||||||
**DEC-015** product language for MVP expanded from English-only
|
|
||||||
to **English + Chinese (Simplified)**. DEC-020 license remains
|
|
||||||
Open / Deferred and is now the only public-release-gating
|
|
||||||
decision outstanding. §4 renamed "Recommended" → "Accepted MVP
|
|
||||||
Defaults" with MODIFIED rows annotated. §6 collapsed to the
|
|
||||||
single remaining DEC-020 item. §7 dated and statused for every
|
|
||||||
decision.
|
|
||||||
- `docs/governance/poc-results-summary.md` bumped to v0.3.0:
|
|
||||||
RISK-PoC-004 closed by the owner-confirmation pass; new
|
|
||||||
RISK-PoC-006 (Android `minSdk` move 24 → 28 for product code)
|
|
||||||
and RISK-PoC-007 (MVP language expansion to en + zh-Hans) added.
|
|
||||||
- `docs/architecture/proof-of-concept-plan.md` bumped to v0.3.0 to
|
|
||||||
promote the audio PoC from PARTIAL PASS to PASS after the Android
|
|
||||||
mobile half was closed; all six PoC plan entries are now PASS.
|
|
||||||
- `docs/governance/product-decision-register.md` bumped to v0.9.4 to
|
|
||||||
promote DEC-011.1 mobile half from Deferred to Accepted (Android),
|
|
||||||
keeping iOS Deferred.
|
|
||||||
- `docs/governance/poc-results-summary.md` bumped to v0.2.0:
|
|
||||||
audio row promoted to PASS, RISK-PoC-001 narrowed from "mobile
|
|
||||||
audio" to "iOS audio only", Android toolchain added to the
|
|
||||||
toolchain table.
|
|
||||||
- `poc/audio-capture-playback-spike/VERIFICATION.md` updated to point
|
|
||||||
at the Android spike for the mobile half.
|
|
||||||
- `poc/README.md` updated to list both audio spike directories.
|
|
||||||
- `docs/architecture/proof-of-concept-plan.md` bumped to v0.2.0 to
|
|
||||||
record PoC outcomes (5 PASS, 1 PARTIAL) and add a Status column.
|
|
||||||
- `docs/security/secure-storage-audit-report.md` bumped to v0.9.3:
|
|
||||||
SS-AUD-001/002/003/005/006 status set to PoC Pass with evidence
|
|
||||||
pointers; SS-TC-003 (Linux) Actual Result populated and Status set
|
|
||||||
to PoC Pass; findings SS-FIND-001..003 added; non-Linux test cases
|
|
||||||
marked Deferred.
|
|
||||||
- `docs/security/diagnostic-redaction-audit-report.md` bumped to
|
|
||||||
v0.9.3: REDACT-TC-001..010 status set to PoC Pass with evidence
|
|
||||||
pointers; export bundle policy §5 populated; findings
|
|
||||||
REDACT-FIND-001..003 added.
|
|
||||||
- `docs/governance/product-decision-register.md` bumped to v0.9.3:
|
|
||||||
owner-confirmed decisions recorded — DEC-014 Accepted
|
|
||||||
(`flutter_rust_bridge` 2.x pinned), DEC-013.1 Accepted (`rusqlite`
|
|
||||||
bundled), DEC-013.2 Accepted (Linux Secret Service preferred,
|
|
||||||
keyutils fallback), DEC-011.1 Accepted (desktop `cpal`) / Deferred
|
|
||||||
(mobile), DEC-022 Accepted (canonical implementation directory
|
|
||||||
layout per README sketch + SAD §7.2), DEC-020 explicitly Deferred
|
|
||||||
and remains a public-release blocker.
|
|
||||||
|
|
||||||
### Added (governance)
|
|
||||||
|
|
||||||
- `docs/governance/poc-results-summary.md` v0.1.0 — single-page
|
|
||||||
reviewer-facing summary of the PoC phase, the toolchain exercised,
|
|
||||||
the owner decisions taken, the audit coverage, and the open risks
|
|
||||||
RISK-PoC-001..005.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- N/A
|
- **`clamp` panic on Info tab open.** `adapter.rs` cast `i64::MIN` to
|
||||||
|
`u128` before clamping, producing `min > max`. Replaced with
|
||||||
|
`.min(i64::MAX as u128) as i64`.
|
||||||
|
- **Non-self client profiles empty.** Added `request_client_db_info()`
|
||||||
|
with graceful fallback for missing fields.
|
||||||
|
- **Server query clients visible in UI.** Delta joins now filter
|
||||||
|
`is_server_query` clients from the channel tree.
|
||||||
|
- **Speaking indicators lost after reconnect.** Event forwarders are
|
||||||
|
reattached after the connection task restarts.
|
||||||
|
- **Channel double-tap join.** 1-second cooldown prevents rapid
|
||||||
|
re-join attempts.
|
||||||
|
- **`getconnectioninfo` errors silently discarded.** Now logged at
|
||||||
|
warning level instead.
|
||||||
|
- **iOS keyboard lag.** Multiple focus/tap handling fixes for iOS 26
|
||||||
|
`TextField` stale-focus regression.
|
||||||
|
- **Storage test isolation.** Temp directories no longer collide across
|
||||||
|
parallel test runs.
|
||||||
|
- **CI submodule fetch.** iOS build job uses best-effort `git submodule
|
||||||
|
update` with `|| true` fallback for the private `silero-coreml`
|
||||||
|
package.
|
||||||
|
- **Stale license inventory.** Regenerated `docs/security/license-
|
||||||
|
inventory.md` to match current `Cargo.lock`.
|
||||||
|
|
||||||
### Security
|
### Notes
|
||||||
|
|
||||||
- N/A
|
- The `silero-coreml` submodule references a private repository
|
||||||
|
(`chanoraapp/silero-coreml`). CI builds skip the iOS unsigned build
|
||||||
|
when the submodule is unavailable.
|
||||||
|
- Default base branch is `main` (previously `product/scaffold-v0`).
|
||||||
|
- DSP chain (AEC / AGC / NS / HPF) is not yet production-tuned.
|
||||||
|
`AudioEffects` struct exists but filters are placeholder-grade.
|
||||||
|
- Desktop push-to-talk is capability-based; global PTT requires
|
||||||
|
OS-specific permissions and is not yet wired end-to-end in the UI.
|
||||||
|
|
||||||
|
## [v0.2.0-beta.1] — Internal Beta first build
|
||||||
|
|
||||||
|
- **Voice in/out wired end-to-end through the Flutter UI.** Per DEC-001
|
||||||
|
this reaches the Internal Beta milestone.
|
||||||
|
- `crates/chanora_audio/` promoted from scaffold to working engine:
|
||||||
|
cpal capture (mic gain, linear resampling to 48 kHz, mono down-mix)
|
||||||
|
and playback (48 kHz stereo). Opus VoIP encoding (20 ms frames).
|
||||||
|
Push-to-talk gate. Live counters.
|
||||||
|
- `crates/chanora_protocol/` extended with voice channels.
|
||||||
|
- `core/chanora_core::ChanoraSession` audio API.
|
||||||
|
- `crates/chanora_bridge/` audio surface for Dart.
|
||||||
|
- Beta UI rewrite with connect, PTT, and audio stats.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- DSP chain not yet implemented (AEC / AGC / NS / HPF deferred).
|
||||||
|
- Capture resampler is linear interpolation (production resampler
|
||||||
|
deferred).
|
||||||
|
- Identity is ephemeral per connect; persistence deferred.
|
||||||
|
- No live event stream — UI polls on timer.
|
||||||
|
- `chanora_diagnostics` still scaffold.
|
||||||
|
- Audio engine is desktop-only in this build.
|
||||||
|
|
||||||
|
## [v0.1.0-alpha.1] — Internal Alpha
|
||||||
|
|
||||||
|
- First Alpha build wires connect → snapshot → disconnect end-to-end
|
||||||
|
from the Flutter UI to a live TeamSpeak-compatible server via the
|
||||||
|
typed Flutter/Rust bridge.
|
||||||
|
- `crates/chanora_protocol/` promoted from scaffold to working adapter.
|
||||||
|
- `crates/chanora_bridge/` wired against `flutter_rust_bridge` 2.12.0.
|
||||||
|
- Alpha UI: server address form, connect button, snapshot view.
|
||||||
|
|
||||||
## Versioning note
|
## Versioning note
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,20 @@
|
|||||||
# apps/chanora_flutter/ — Flutter application (separate toolchain)
|
# apps/chanora_flutter/ — Flutter application (separate toolchain)
|
||||||
# core/chanora_core/ — top-level Rust API + orchestration
|
# core/chanora_core/ — top-level Rust API + orchestration
|
||||||
# crates/chanora_protocol/ — tsclientlib isolation
|
# crates/chanora_protocol/ — tsclientlib isolation
|
||||||
|
# crates/chanora_resolver/ — TeamSpeak address resolution
|
||||||
# crates/chanora_state/ — snapshot, deltas, reducers
|
# crates/chanora_state/ — snapshot, deltas, reducers
|
||||||
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
||||||
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
||||||
# crates/chanora_diagnostics/ — logs, redaction, export
|
# crates/chanora_diagnostics/ — logs, redaction, export
|
||||||
|
# crates/chanora_prefetch — server-resolution prefetch cache/policy
|
||||||
# crates/chanora_bridge/ — Flutter/Rust typed DTOs + glue
|
# crates/chanora_bridge/ — Flutter/Rust typed DTOs + glue
|
||||||
#
|
#
|
||||||
# The Flutter app is not part of this Cargo workspace; it is owned by
|
# The Flutter app is not part of this Cargo workspace; it is owned by
|
||||||
# Gradle / Xcode / Flutter tooling under apps/chanora_flutter/.
|
# Gradle / Xcode / Flutter tooling under apps/chanora_flutter/.
|
||||||
#
|
#
|
||||||
# Non-promotion reminder (proof-of-concept-plan.md §4): nothing under
|
# Proof-of-concept spikes are archived separately in the private
|
||||||
# poc/ is product code. The crates here are blank scaffolds at this
|
# chanoraapp/chanora-poc repository. Promotion of PoC findings into
|
||||||
# stage; promotion of PoC code happens crate-by-crate with explicit
|
# product code happens crate-by-crate with explicit audit-trail commits.
|
||||||
# audit-trail commits.
|
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
@@ -30,24 +31,17 @@ members = [
|
|||||||
"crates/chanora_audio",
|
"crates/chanora_audio",
|
||||||
"crates/chanora_storage",
|
"crates/chanora_storage",
|
||||||
"crates/chanora_diagnostics",
|
"crates/chanora_diagnostics",
|
||||||
|
"crates/chanora_prefetch",
|
||||||
"crates/chanora_bridge",
|
"crates/chanora_bridge",
|
||||||
|
"crates/chanora_resolver",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Excludes: the PoC trees stay outside the product workspace so that
|
|
||||||
# `cargo check --workspace` at the root does not pull in PoC builds.
|
|
||||||
exclude = [
|
exclude = [
|
||||||
"poc/tsclientlib-connect-spike",
|
|
||||||
"poc/secure-storage-spike",
|
|
||||||
"poc/sqlite-storage-spike",
|
|
||||||
"poc/diagnostics-redaction-spike",
|
|
||||||
"poc/audio-capture-playback-spike",
|
|
||||||
"poc/audio-capture-playback-android-spike",
|
|
||||||
"poc/flutter_rust_bridge_hello",
|
|
||||||
"apps/chanora_flutter",
|
"apps/chanora_flutter",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.1-pre"
|
version = "0.2.0-beta.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
authors = ["The Chanora Project Contributors"]
|
authors = ["The Chanora Project Contributors"]
|
||||||
@@ -62,3 +56,29 @@ readme = "README.md"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
# DEC-032 exit step: override the `cmake` crate with the fork carrying
|
||||||
|
# cmake-rs PR #257 (https://github.com/rust-lang/cmake-rs/pull/257).
|
||||||
|
# The patch forwards `ANDROID_ABI` and `ANDROID_PLATFORM` environment
|
||||||
|
# variables (set per-invocation by cargo-ndk >= 4.x and reinforced by
|
||||||
|
# our SDD-118 item 5 cleanEnv map) to the child `cmake` invocation as
|
||||||
|
# `-D` variables, so audiopus_sys's libopus CMake build picks up the
|
||||||
|
# correct per-ABI target on multi-ABI Android builds. Without it the
|
||||||
|
# child cmake falls through to the NDK toolchain file's default and
|
||||||
|
# armeabi-v7a / x86_64 fail to compile.
|
||||||
|
#
|
||||||
|
# Pinned by exact commit SHA (the `android-build` branch tip on
|
||||||
|
# pr2502/cmake-rs as of 2026-05-18) so the patch is reproducible.
|
||||||
|
# Re-evaluate and remove once PR #257 merges and a fresh `cmake`
|
||||||
|
# release lands on crates.io (current upstream is 0.7.2).
|
||||||
|
# Patch tsproto-types to left-pad P-256 coordinates that are shorter than
|
||||||
|
# 32 bytes. BigInt::to_bytes_be() strips leading zero bytes; when a
|
||||||
|
# coordinate happens to start with 0x00 (~0.8 % probability per coordinate)
|
||||||
|
# the upstream code rejects it with WrongPublicKeyLength, breaking the
|
||||||
|
# init-server handshake. The fix zero-pads to the field size instead.
|
||||||
|
# Fork: https://github.com/EdisonJwa/tsclientlib/tree/fix/p256-short-coordinate-pad
|
||||||
|
[patch."https://github.com/ReSpeak/tsclientlib.git"]
|
||||||
|
tsproto-types = { git = "https://github.com/EdisonJwa/tsclientlib.git", branch = "fix/p256-short-coordinate-pad" }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
cmake = { git = "https://github.com/pr2502/cmake-rs", rev = "bdad5edc569d82151922c5c6c4685b1563f12aa1" }
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
Chanora is dual-licensed under either of:
|
|
||||||
|
|
||||||
* Apache License, Version 2.0
|
|
||||||
([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0)
|
|
||||||
* MIT license
|
|
||||||
([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT)
|
|
||||||
|
|
||||||
at your option.
|
|
||||||
|
|
||||||
This dual-license model is recorded in
|
|
||||||
`docs/governance/product-decision-register.md` (decision DEC-020,
|
|
||||||
Accepted on 2026-05-14).
|
|
||||||
|
|
||||||
## Contribution
|
|
||||||
|
|
||||||
Unless you explicitly state otherwise, any contribution intentionally
|
|
||||||
submitted for inclusion in Chanora by you, as defined in the
|
|
||||||
Apache-2.0 license, shall be dual-licensed as above, without any
|
|
||||||
additional terms or conditions.
|
|
||||||
|
|
||||||
## Third-party software
|
|
||||||
|
|
||||||
Chanora bundles or links to third-party software whose own licenses
|
|
||||||
apply. See `NOTICE` for an inventory and the per-dependency license
|
|
||||||
texts that ship with the released artefacts. The legal review of the
|
|
||||||
full dependency set is tracked by DEC-012 and must complete before any
|
|
||||||
public/store release.
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for describing the origin of the Work and
|
|
||||||
reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Support. While redistributing the Work or
|
|
||||||
Derivative Works thereof, You may choose to offer, and charge a
|
|
||||||
fee for, acceptance of support, warranty, indemnity, or other
|
|
||||||
liability obligations and/or rights consistent with this License.
|
|
||||||
However, in accepting such obligations, You may act only on Your
|
|
||||||
own behalf and on Your sole responsibility, not on behalf of any
|
|
||||||
other Contributor, and only if You agree to indemnify, defend,
|
|
||||||
and hold each Contributor harmless for any liability incurred by,
|
|
||||||
or claims asserted against, such Contributor by reason of your
|
|
||||||
accepting any such warranty or support.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2026 The Chanora Project Contributors
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 The Chanora Project Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -23,58 +23,65 @@ tracked by DEC-012 in
|
|||||||
docs/governance/product-decision-register.md and must complete before
|
docs/governance/product-decision-register.md and must complete before
|
||||||
any public/store release.
|
any public/store release.
|
||||||
|
|
||||||
Direct Rust dependencies of the proof-of-concept code (current as of
|
Direct Rust dependencies of the MVP product code (as of v1.0.0-rc.1):
|
||||||
2026-05-14):
|
|
||||||
|
|
||||||
* tsclientlib — MIT OR Apache-2.0
|
Protocol + audio
|
||||||
|
* tsclientlib — MIT OR Apache-2.0
|
||||||
https://github.com/ReSpeak/tsclientlib
|
https://github.com/ReSpeak/tsclientlib
|
||||||
* flutter_rust_bridge — MIT
|
* tsproto / tsproto-packets — MIT OR Apache-2.0 (workspace of tsclientlib)
|
||||||
https://github.com/fzyzcjy/flutter_rust_bridge
|
* cpal — Apache-2.0
|
||||||
* cpal — Apache-2.0
|
|
||||||
https://github.com/RustAudio/cpal
|
https://github.com/RustAudio/cpal
|
||||||
* rusqlite — MIT
|
* audiopus — MIT OR Apache-2.0
|
||||||
|
|
||||||
|
Bridge + framework glue
|
||||||
|
* flutter_rust_bridge — MIT
|
||||||
|
https://github.com/fzyzcjy/flutter_rust_bridge
|
||||||
|
* tokio — MIT
|
||||||
|
* futures — MIT OR Apache-2.0
|
||||||
|
* thiserror — MIT OR Apache-2.0
|
||||||
|
* tracing / tracing-subscriber / tracing-android — MIT
|
||||||
|
* serde — MIT OR Apache-2.0
|
||||||
|
|
||||||
|
Storage + secure storage
|
||||||
|
* rusqlite (bundled) — MIT
|
||||||
https://github.com/rusqlite/rusqlite
|
https://github.com/rusqlite/rusqlite
|
||||||
* keyring — MIT OR Apache-2.0
|
* libsqlite3-sys — MIT
|
||||||
|
* chacha20poly1305 — Apache-2.0 OR MIT
|
||||||
|
* rand — MIT OR Apache-2.0
|
||||||
|
* zeroize — MIT OR Apache-2.0
|
||||||
|
* base64 — MIT OR Apache-2.0
|
||||||
|
* keyring — MIT OR Apache-2.0
|
||||||
https://github.com/hwchen/keyring-rs
|
https://github.com/hwchen/keyring-rs
|
||||||
* linux-keyutils — BSD-3-Clause
|
|
||||||
* hound — Apache-2.0
|
|
||||||
* regex — MIT OR Apache-2.0
|
|
||||||
* serde / serde_json — MIT OR Apache-2.0
|
|
||||||
* tokio — MIT
|
|
||||||
* tracing / tracing-subscriber — MIT
|
|
||||||
* jni — MIT OR Apache-2.0
|
|
||||||
* ndk-context — MIT OR Apache-2.0
|
|
||||||
* android_logger — MIT OR Apache-2.0
|
|
||||||
* thiserror — MIT OR Apache-2.0
|
|
||||||
* zeroize — MIT OR Apache-2.0
|
|
||||||
* indoc — MIT OR Apache-2.0
|
|
||||||
* tempfile — MIT OR Apache-2.0
|
|
||||||
* once_cell — MIT OR Apache-2.0
|
|
||||||
* anyhow — MIT OR Apache-2.0
|
|
||||||
* clap — MIT OR Apache-2.0
|
|
||||||
* futures — MIT OR Apache-2.0
|
|
||||||
* serial_test — MIT
|
|
||||||
|
|
||||||
Direct Flutter / Dart dependencies of the FRB hello PoC:
|
Android JNI
|
||||||
|
* jni — MIT OR Apache-2.0
|
||||||
|
* ndk-context — MIT OR Apache-2.0
|
||||||
|
|
||||||
* Flutter framework — BSD-3-Clause
|
Direct Flutter / Dart dependencies of the MVP product code:
|
||||||
* flutter_rust_bridge — MIT (Dart side mirrors the Rust side)
|
|
||||||
|
|
||||||
Direct Android dependencies of the Android audio spike:
|
* Flutter framework — BSD-3-Clause
|
||||||
|
* flutter_rust_bridge — MIT (Dart side mirrors the Rust side)
|
||||||
|
* connectivity_plus — BSD-3-Clause
|
||||||
|
* path_provider — BSD-3-Clause
|
||||||
|
* intl — BSD-3-Clause
|
||||||
|
* cupertino_icons — MIT
|
||||||
|
* haptic_kit — MIT
|
||||||
|
https://github.com/erykkruk/flutter_vibration_animation
|
||||||
|
* freezed_annotation — MIT
|
||||||
|
* flutter_lints (dev) — BSD-3-Clause
|
||||||
|
* build_runner (dev) — BSD-3-Clause
|
||||||
|
|
||||||
|
Direct Android-app dependencies (apps/chanora_flutter/android/):
|
||||||
|
|
||||||
* androidx.core:core-ktx, androidx.appcompat:appcompat — Apache-2.0
|
* androidx.core:core-ktx, androidx.appcompat:appcompat — Apache-2.0
|
||||||
* Android NDK r26.x runtime libraries — Apache-2.0 / per-component licences
|
* Android NDK r26.x runtime libraries — Apache-2.0 / per-component licences
|
||||||
* Kotlin stdlib — Apache-2.0
|
* Kotlin stdlib — Apache-2.0
|
||||||
* Gradle wrapper — Apache-2.0
|
* Gradle wrapper — Apache-2.0
|
||||||
|
|
||||||
This list reflects PoC code only. The product-code dependency set
|
Transitive dependencies are not enumerated here. The full machine-
|
||||||
(`apps/chanora_flutter/`, `crates/chanora_*`) is not yet established;
|
generated license inventory for a release build can be produced with
|
||||||
its full license inventory will be re-collected and reviewed under
|
`cargo about generate` (Rust) and Flutter's `LicenseRegistry` (Dart);
|
||||||
DEC-012 before public release.
|
that output must be shipped with each released artefact. See
|
||||||
|
docs/security/dependency-and-supply-chain-report.md for the supply-
|
||||||
Transitive dependencies are not enumerated here. A complete
|
chain audit record and docs/governance/legal-review-readiness.md for
|
||||||
machine-generated inventory must be produced by the build tooling
|
the DEC-012 sign-off checklist.
|
||||||
(e.g. `cargo about` for Rust and the Flutter LicenseRegistry for Dart)
|
|
||||||
and shipped with released artefacts. See
|
|
||||||
docs/security/dependency-and-supply-chain-report.md for the audit
|
|
||||||
record.
|
|
||||||
|
|||||||
@@ -46,13 +46,24 @@ Current platform policy:
|
|||||||
|
|
||||||
| Platform | Baseline |
|
| Platform | Baseline |
|
||||||
|---|---|
|
|---|---|
|
||||||
| iOS / iPadOS runtime target | iOS 13+ unless Flutter, plugin, audio, or product constraints require raising it |
|
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
||||||
|
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
||||||
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
||||||
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
||||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||||
|
|
||||||
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
||||||
|
|
||||||
|
Apple CoreML VAD development requires the private `silero-coreml` SwiftPM package checked out as a sibling of this repository, so the app checkout and package checkout share the same parent directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace/
|
||||||
|
chanora/
|
||||||
|
silero-coreml/
|
||||||
|
```
|
||||||
|
|
||||||
|
The iOS and macOS Xcode projects reference that package via `../../../../silero-coreml` from their project files. GitHub CI skips the unsigned iOS build when the sibling package is unavailable, but local Apple builds need that checkout.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
@@ -113,6 +124,46 @@ The current recommended MVP scope is:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Desktop Push-to-Talk
|
||||||
|
|
||||||
|
Chanora's desktop Push-to-Talk (PTT) follows a **capability-based** design (see
|
||||||
|
[`docs/architecture/desktop-ptt-architecture.md`](docs/architecture/desktop-ptt-architecture.md)).
|
||||||
|
Focused PTT — the user holds a bound key or mouse button inside the
|
||||||
|
focused Chanora window — is mandatory on Windows, macOS, and Linux.
|
||||||
|
Global PTT (recognised while the application is not focused) is
|
||||||
|
**capability-dependent**: it requires the operating system, the
|
||||||
|
user-granted permission set, the display server, and the available
|
||||||
|
input backend to all permit it.
|
||||||
|
|
||||||
|
The application reports a `PttCapabilityLevel` (`L0Focused`,
|
||||||
|
`L1GlobalShortcut`, `L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`)
|
||||||
|
that matches actual runtime behaviour, not the platform's theoretical
|
||||||
|
maximum. The UI capability badge shows the live value.
|
||||||
|
|
||||||
|
Per-platform strategy (resolved by owner rulings 2026-05-15, see
|
||||||
|
`docs/governance/product-decision-register.md` DEC-023 through
|
||||||
|
DEC-028):
|
||||||
|
|
||||||
|
* **Windows** — Raw Input first, low-level keyboard hook fallback,
|
||||||
|
Focused PTT terminal fallback. Mouse side buttons supported.
|
||||||
|
P0 / MVP.
|
||||||
|
* **macOS** — permission-aware Event Tap with Focused PTT fallback;
|
||||||
|
Global PTT upgrades asynchronously when the user grants Input
|
||||||
|
Monitoring / Accessibility. P0 / MVP.
|
||||||
|
* **Linux** — officially tested on **GNOME on Wayland** using the
|
||||||
|
`org.freedesktop.portal.GlobalShortcuts` interface; every other
|
||||||
|
Linux environment falls back to Focused PTT. Release notes do
|
||||||
|
not claim Global PTT support outside the tested compositor.
|
||||||
|
* Raw key codes, scan codes, virtual-key values, keysyms, and
|
||||||
|
key-press timing sequences are never logged or included in the
|
||||||
|
user-initiated diagnostic export. The diagnostic export carries
|
||||||
|
only capability level, backend identifier, and bound input
|
||||||
|
class.
|
||||||
|
|
||||||
|
A missed-key-up watchdog (default 30 s) clears `transmit_active`
|
||||||
|
when the OS suppresses a key-up event so a stuck-PTT bug class is
|
||||||
|
ruled out by construction.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
The repository documentation is expected to live under `docs/`.
|
The repository documentation is expected to live under `docs/`.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Chanora — Third-party license inventory
|
||||||
|
|
||||||
|
This document enumerates every third-party crate that ships in a
|
||||||
|
release build of Chanora and the license under which Chanora
|
||||||
|
redistributes it. Generated by `cargo about generate` from
|
||||||
|
`about.toml` at the repository root.
|
||||||
|
|
||||||
|
Chanora itself is dual-licensed under
|
||||||
|
[Apache License 2.0](../../LICENSE-APACHE) or the
|
||||||
|
[MIT License](../../LICENSE-MIT) at the recipient's option (DEC-020
|
||||||
|
in `docs/governance/product-decision-register.md`). The crates
|
||||||
|
listed below carry their own licenses and are redistributed under
|
||||||
|
those terms.
|
||||||
|
|
||||||
|
## Licenses in use
|
||||||
|
|
||||||
|
| License | Crate count |
|
||||||
|
|---------|-------------|
|
||||||
|
{{#each overview}}
|
||||||
|
| `{{name}}` | {{count}} |
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
| Crate | Version | License | Source |
|
||||||
|
|-------|---------|---------|--------|
|
||||||
|
{{#each licenses}}
|
||||||
|
{{#each used_by}}
|
||||||
|
| {{crate.name}} | {{crate.version}} | `{{../name}}` | {{#if crate.repository}}<{{crate.repository}}>{{else}}—{{/if}} |
|
||||||
|
{{/each}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
## Full license texts
|
||||||
|
|
||||||
|
{{#each licenses}}
|
||||||
|
### {{name}}
|
||||||
|
|
||||||
|
```
|
||||||
|
{{text}}
|
||||||
|
```
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Regenerate with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo about generate --output-file docs/security/license-inventory.md about-md.hbs
|
||||||
|
cargo about generate --output-file docs/security/license-inventory.html about.hbs
|
||||||
|
```
|
||||||
|
|
||||||
|
This artefact supports the DEC-012 legal review handoff at
|
||||||
|
`docs/governance/legal-review-readiness.md`.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Chanora — Third-party license inventory</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
max-width: 80ch; margin: 2em auto; padding: 0 1em; line-height: 1.5; }
|
||||||
|
h1, h2 { border-bottom: 1px solid #ccc; padding-bottom: 0.2em; }
|
||||||
|
.crate { margin-bottom: 1.5em; padding: 0.6em 1em;
|
||||||
|
border: 1px solid #ddd; border-radius: 4px; background: #fafafa; }
|
||||||
|
.crate h3 { margin: 0 0 0.4em 0; font-size: 1em; }
|
||||||
|
.license { font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
background: #eef; padding: 0.1em 0.4em; border-radius: 3px; }
|
||||||
|
pre { background: #f0f0f0; padding: 1em; overflow-x: auto;
|
||||||
|
max-height: 30em; font-size: 0.85em; }
|
||||||
|
.footer { margin-top: 3em; padding-top: 1em; border-top: 1px solid #ccc;
|
||||||
|
color: #666; font-size: 0.85em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Chanora — Third-party license inventory</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This page enumerates every third-party crate that ships in a release
|
||||||
|
build of Chanora and the license under which Chanora redistributes
|
||||||
|
it. Generated by <code>cargo about generate</code> from
|
||||||
|
<code>about.toml</code> at the repository root; regenerate via the
|
||||||
|
<code>just license-inventory</code> recipe.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Chanora itself is dual-licensed under
|
||||||
|
<a href="../../LICENSE-APACHE">Apache License 2.0</a> or the
|
||||||
|
<a href="../../LICENSE-MIT">MIT License</a> at the recipient's option
|
||||||
|
(see DEC-020 in
|
||||||
|
<code>docs/governance/product-decision-register.md</code>).
|
||||||
|
The crates listed below carry their own licenses and are
|
||||||
|
redistributed under those terms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Licenses in use</h2>
|
||||||
|
<ul>
|
||||||
|
{{#each overview}}
|
||||||
|
<li><span class="license">{{name}}</span> — used by {{count}} crate(s).</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Crates</h2>
|
||||||
|
{{#each licenses}}
|
||||||
|
{{#each used_by}}
|
||||||
|
<div class="crate">
|
||||||
|
<h3>{{crate.name}} {{crate.version}}</h3>
|
||||||
|
<p>License: <span class="license">{{../name}}</span></p>
|
||||||
|
{{#if crate.repository}}<p>Source: <a href="{{crate.repository}}">{{crate.repository}}</a></p>{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<h2>Full license texts</h2>
|
||||||
|
{{#each licenses}}
|
||||||
|
<h3>{{name}}</h3>
|
||||||
|
<pre>{{text}}</pre>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Regenerate with <code>cargo about generate --output-file docs/security/license-inventory.html about.hbs</code>.
|
||||||
|
This artefact supports the DEC-012 legal review handoff at
|
||||||
|
<code>docs/governance/legal-review-readiness.md</code>.
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# cargo-about configuration for the Chanora workspace.
|
||||||
|
#
|
||||||
|
# Lists the SPDX licenses the workspace is permitted to depend on
|
||||||
|
# transitively. Each entry corresponds to a license that has been
|
||||||
|
# audited and accepted under DEC-020 (`docs/governance/
|
||||||
|
# product-decision-register.md`).
|
||||||
|
#
|
||||||
|
# Generate the human-readable inventory:
|
||||||
|
#
|
||||||
|
# cargo about generate \
|
||||||
|
# --output-file docs/security/license-inventory.html \
|
||||||
|
# about.hbs
|
||||||
|
# cargo about generate \
|
||||||
|
# --output-file docs/security/license-inventory.md \
|
||||||
|
# about-md.hbs
|
||||||
|
#
|
||||||
|
# The generated artefacts are checked into `docs/security/` and
|
||||||
|
# referenced from `docs/governance/legal-review-readiness.md` §5.
|
||||||
|
|
||||||
|
accepted = [
|
||||||
|
"Apache-2.0",
|
||||||
|
"MIT",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Zlib",
|
||||||
|
"0BSD",
|
||||||
|
"BSL-1.0",
|
||||||
|
"MPL-2.0",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"CC0-1.0",
|
||||||
|
"CDLA-Permissive-2.0",
|
||||||
|
"OpenSSL",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Targets we ship binaries for. Includes the three desktop targets,
|
||||||
|
# Android, and iOS so that platform-specific crates surface even on
|
||||||
|
# a Linux build host.
|
||||||
|
targets = [
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"aarch64-linux-android",
|
||||||
|
"x86_64-pc-windows-msvc",
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"aarch64-apple-ios",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build-script-only crates should not influence the redistributed
|
||||||
|
# license inventory.
|
||||||
|
ignore-build-dependencies = false
|
||||||
|
ignore-dev-dependencies = true
|
||||||
|
ignore-transitive-dependencies = false
|
||||||
|
|
||||||
|
# Per-crate license clarifications. Anchored on a manual read of
|
||||||
|
# the upstream LICENSE file when the crate ships a `license-file`
|
||||||
|
# entry rather than an SPDX `license` expression.
|
||||||
|
[allo-isolate.clarify]
|
||||||
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[[allo-isolate.clarify.git]]
|
||||||
|
path = "LICENSE"
|
||||||
|
checksum = "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4"
|
||||||
@@ -15,10 +15,7 @@ migration:
|
|||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
- platform: android
|
- platform: windows
|
||||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
|
||||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
|
||||||
- platform: linux
|
|
||||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ GeneratedPluginRegistrant.java
|
|||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
/app/src/main/jniLibs/
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +7,52 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SDD-073 / SDD-109 trace: load release-signing material from a
|
||||||
|
// developer-local gradle.properties file (preferred) or from environment
|
||||||
|
// variables (CI). Key custody is governed by SDD-073 item 5 and the
|
||||||
|
// CI signing assertion hook in SDD-109 item 4. NO key material is ever
|
||||||
|
// committed to the repository; the four properties below are looked up
|
||||||
|
// at configure time and may be absent on developer machines.
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystorePropertiesFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveSigningProp(name: String): String? {
|
||||||
|
// Precedence: gradle property -> key.properties file -> environment variable.
|
||||||
|
val fromProject = if (project.hasProperty(name)) project.property(name)?.toString() else null
|
||||||
|
val fromFile = keystoreProperties.getProperty(name)
|
||||||
|
val fromEnv = System.getenv(name)
|
||||||
|
return fromProject ?: fromFile ?: fromEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseStoreFile = resolveSigningProp("CHANORA_RELEASE_STORE_FILE")
|
||||||
|
val releaseStorePassword = resolveSigningProp("CHANORA_RELEASE_STORE_PASSWORD")
|
||||||
|
val releaseKeyAlias = resolveSigningProp("CHANORA_RELEASE_KEY_ALIAS")
|
||||||
|
val releaseKeyPassword = resolveSigningProp("CHANORA_RELEASE_KEY_PASSWORD")
|
||||||
|
val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() }
|
||||||
|
val splitPerAbiRequested = project.hasProperty("split-per-abi") ||
|
||||||
|
gradle.startParameter.taskNames.any { taskName ->
|
||||||
|
taskName.contains("split-per-abi", ignoreCase = true)
|
||||||
|
}
|
||||||
|
val configuredAbiSet = listOf("arm64-v8a", "x86_64")
|
||||||
|
val flutterTargetPlatformArg = gradle.startParameter.projectProperties["target-platform"]
|
||||||
|
?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.firstOrNull()
|
||||||
|
val singleAbiFromFlutterTargetPlatform = when (flutterTargetPlatformArg) {
|
||||||
|
"android-arm64" -> "arm64-v8a"
|
||||||
|
"android-x64" -> "x86_64"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val effectivePackagingAbis: List<String> = when {
|
||||||
|
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
|
||||||
|
splitPerAbiRequested -> configuredAbiSet
|
||||||
|
else -> configuredAbiSet
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.chanora.chanora_flutter"
|
namespace = "app.chanora.chanora_flutter"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -21,29 +69,568 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.chanora.chanora_flutter"
|
applicationId = "app.chanora.chanora_flutter"
|
||||||
// DEC-004 (register v0.9.5): Android minimum API 28 (Android 9.0).
|
// DEC-004 (register v0.9.5) / SDD-073 item 1: Android minimum API
|
||||||
// Raised from the original recommendation of API 24 by owner ruling
|
// 28 (Android 9.0). Raised from the original recommendation of
|
||||||
// on 2026-05-14 for a simpler audio path (AAudio stable from API 28)
|
// API 24 by owner ruling on 2026-05-14 for a simpler audio path
|
||||||
// and a narrower compatibility / scoped-storage surface. Do NOT
|
// (AAudio stable from API 28) and a narrower compatibility /
|
||||||
// lower without re-opening DEC-004.
|
// scoped-storage surface. Do NOT lower without re-opening DEC-004.
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
// DEC-005: target the Google Play-required API level on upload date.
|
// DEC-005 / SDD-073 item 2 / SRS-188: target the Google
|
||||||
// Flutter's default is kept; release-time CI must verify this still
|
// Play-required API level on upload date. Flutter's default is
|
||||||
// satisfies the current Play policy.
|
// kept; release-time CI must verify this still satisfies the
|
||||||
|
// current Play policy (SDD-109 release-inspection hook).
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
|
// SDD-073 item 4: NDK ABIs pinned to arm64-v8a, armeabi-v7a,
|
||||||
|
// and x86_64. Other ABIs (x86, mips, …) shall not be packaged.
|
||||||
|
// Per-ABI delivery is handled by the AAB bundle splits below
|
||||||
|
// (SDD-109 item 2) rather than a fat APK.
|
||||||
|
// When Flutter --split-per-abi is active, skip ndk.abiFilters to avoid
|
||||||
|
// conflicting with the Gradle splits.abi mechanism that Flutter injects.
|
||||||
|
// The per-ABI filtering is still handled by the jniLibs excludes below
|
||||||
|
// and the Rust cargo-ndk per-ABI build tasks.
|
||||||
|
if (!splitPerAbiRequested) {
|
||||||
|
ndk {
|
||||||
|
// DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
|
||||||
|
// (arm64-v8a + x86_64) restored after the audiopus_sys
|
||||||
|
// ANDROID_ABI propagation gap was closed by the workspace
|
||||||
|
// [patch.crates-io] override pinning `cmake` to the fork
|
||||||
|
// carrying cmake-rs PR #257 (forwards ANDROID_ABI /
|
||||||
|
// ANDROID_PLATFORM as -D variables to the child cmake
|
||||||
|
// invocation). See Cargo.toml [patch.crates-io] block and
|
||||||
|
// docs/governance/product-decision-register.md DEC-032.
|
||||||
|
abiFilters += effectivePackagingAbis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-073 item 5 / SDD-109 item 4: release signing is sourced from
|
||||||
|
// CI-provided credentials. If any of the four properties is absent,
|
||||||
|
// the signingConfig is intentionally NOT registered so the release
|
||||||
|
// build fails fast at task-execution time with a clear message,
|
||||||
|
// rather than silently falling back to the debug keystore.
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(releaseStoreFile!!)
|
||||||
|
storePassword = releaseStorePassword
|
||||||
|
keyAlias = releaseKeyAlias
|
||||||
|
keyPassword = releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// SDD-073 item 5: release builds MUST use a non-debug
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// signing config. When the four CHANORA_RELEASE_* inputs
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// are present (CI), wire the dedicated release signingConfig.
|
||||||
|
// When they are absent (developer machine without keys),
|
||||||
|
// leave signingConfig unset so any attempt to assemble a
|
||||||
|
// release artefact fails with the canonical Gradle error
|
||||||
|
// "No signing config provided for variant release" — this
|
||||||
|
// matches the SDD-109 CI signing assertion stance and
|
||||||
|
// prevents accidental debug-key-signed release outputs.
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"[SDD-073] Release signing credentials are not configured " +
|
||||||
|
"(CHANORA_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / " +
|
||||||
|
"_KEY_PASSWORD). Release tasks will fail; debug fallback " +
|
||||||
|
"is intentionally disabled per SDD-073 item 5."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-073 item 6: enable R8 + resource shrinking for release.
|
||||||
|
// Keep rules live in proguard-rules.pro; see that file for
|
||||||
|
// the JNI / FRB / native-method retention stance.
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
debug {
|
||||||
|
// SDD-073 item 6: debug builds run unminified for developer
|
||||||
|
// ergonomics and stack-trace readability.
|
||||||
|
isMinifyEnabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
if (singleAbiFromFlutterTargetPlatform != null) {
|
||||||
|
val excludedAbis = configuredAbiSet.filter { it != singleAbiFromFlutterTargetPlatform } +
|
||||||
|
listOf("armeabi-v7a", "x86")
|
||||||
|
excludes += excludedAbis.map { abi -> "lib/$abi/**" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-109 item 2: Android App Bundle (.aab) split configuration.
|
||||||
|
// Combined with the SDD-073 abiFilters set, this yields two
|
||||||
|
// native splits (arm64-v8a, x86_64), per-language resource
|
||||||
|
// delivery, and per-density resource delivery. Each device
|
||||||
|
// receives only its matching ABI .so — no cross-ABI bundling.
|
||||||
|
bundle {
|
||||||
|
language {
|
||||||
|
enableSplit = true
|
||||||
|
}
|
||||||
|
density {
|
||||||
|
enableSplit = true
|
||||||
|
}
|
||||||
|
abi {
|
||||||
|
enableSplit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONNX Runtime native library for Silero VAD.
|
||||||
|
// The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
|
||||||
|
// (`load-dynamic` feature). The AAR ships the .so for arm64-v8a,
|
||||||
|
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
||||||
|
dependencies {
|
||||||
|
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SDD-118: AndroidBridgeBuildAutomation
|
||||||
|
//
|
||||||
|
// Auto-build the `chanora_bridge` Rust cdylib via `cargo-ndk` and stage the
|
||||||
|
// resulting per-ABI `.so` files into `src/main/jniLibs/<abi>/` so AGP picks
|
||||||
|
// them up during the normal JNI-lib merge step. No manual `cargo ndk`
|
||||||
|
// invocations and no parent-shell environment mutation are required.
|
||||||
|
//
|
||||||
|
// Cross-trace: SDD-073 (Android build config — minSdk, ABI filters),
|
||||||
|
// SDD-105 (JNI bootstrap — consumer of the produced .so),
|
||||||
|
// SDD-109 (AAB pipeline — downstream packaging consumer of staged jniLibs).
|
||||||
|
//
|
||||||
|
// SDD-118 item 12 (caching across clean builds): cargo's `target/` directory
|
||||||
|
// lives at the repository root (outside `apps/chanora_flutter/android/`) and
|
||||||
|
// is intentionally OUTSIDE Gradle's `clean` scope. Do NOT register `target/`
|
||||||
|
// (or any subpath) as a Gradle output; doing so would make `./gradlew clean`
|
||||||
|
// destroy cargo's incremental cache.
|
||||||
|
//
|
||||||
|
// SDD-118 item 11 (idempotency): Gradle inputs/outputs gate whether the task
|
||||||
|
// runs at all; cargo's own incremental cache decides whether the run actually
|
||||||
|
// relinks. No manual timestamp guards.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SDD-118 item 2: cargo workspace root (repository root). From the :app
|
||||||
|
// project (apps/chanora_flutter/android/app) we ascend three levels to reach
|
||||||
|
// the cargo workspace at the repository root where Cargo.toml / Cargo.lock /
|
||||||
|
// crates/ live. rootProject.projectDir is the Flutter Android root project
|
||||||
|
// (apps/chanora_flutter/android), so three levels up reaches the repo root.
|
||||||
|
val cargoWorkspaceRoot: File = rootProject.projectDir.resolve("../../..").canonicalFile
|
||||||
|
|
||||||
|
// SDD-118 item 6 (extended): NDK prebuilt host tag. The NDK ships
|
||||||
|
// host-specific prebuilt toolchains under
|
||||||
|
// `$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/<host-tag>/…`; the tag is
|
||||||
|
// the only path component that varies across developer/CI hosts. Detect
|
||||||
|
// at configure time so the same Gradle file builds on Linux, macOS, and
|
||||||
|
// Windows CI runners without manual editing.
|
||||||
|
val ndkHostTag: String = when {
|
||||||
|
org.gradle.internal.os.OperatingSystem.current().isMacOsX -> "darwin-x86_64"
|
||||||
|
org.gradle.internal.os.OperatingSystem.current().isWindows -> "windows-x86_64"
|
||||||
|
else -> "linux-x86_64"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-118 item 2: minimum API level source of truth — the
|
||||||
|
// `chanora.android.minSdk` Gradle property mandated by SDD-073 item 1,
|
||||||
|
// defaulting to 28 per DEC-004. Overridable via `-Pchanora.android.minSdk=…`
|
||||||
|
// or via `gradle.properties`. Do NOT hard-code a numeric API level elsewhere.
|
||||||
|
val chanoraAndroidMinSdk: Provider<String> =
|
||||||
|
providers.gradleProperty("chanora.android.minSdk").orElse("28")
|
||||||
|
|
||||||
|
// SDD-118 item 6: explicit Rust-triple ↔ Android-ABI mapping. cargo-ndk
|
||||||
|
// performs the triple mapping internally; we only pass the Android ABI name
|
||||||
|
// to its `-t` flag, but we need the Rust triple to locate `target/<triple>/…`
|
||||||
|
// for the per-ABI copy and for the inputs/outputs declaration.
|
||||||
|
val abiToRustTriple: Map<String, String> = mapOf(
|
||||||
|
"arm64-v8a" to "aarch64-linux-android",
|
||||||
|
"x86_64" to "x86_64-linux-android",
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDD-118 item 3: ABI set derived from the existing
|
||||||
|
// `android.defaultConfig.ndk.abiFilters` declaration owned by SDD-073 item 4
|
||||||
|
// so that the build-automation list and the AGP packaging list are guaranteed
|
||||||
|
// in sync. Any unsupported ABI fails fast.
|
||||||
|
val configuredAbis: List<String> = when {
|
||||||
|
singleAbiFromFlutterTargetPlatform != null -> listOf(singleAbiFromFlutterTargetPlatform)
|
||||||
|
splitPerAbiRequested -> configuredAbiSet
|
||||||
|
else -> android.defaultConfig.ndk.abiFilters.toList()
|
||||||
|
}
|
||||||
|
configuredAbis.forEach { abi ->
|
||||||
|
require(abiToRustTriple.containsKey(abi)) {
|
||||||
|
"[SDD-118] ABI '$abi' is not supported by SDD-118; see SDD-073 item 4. " +
|
||||||
|
"Supported ABIs: ${abiToRustTriple.keys.joinToString(", ")}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-118 item 9: preflight check task. Fails loudly with actionable
|
||||||
|
// remediation commands if cargo / cargo-ndk / required rustup targets are
|
||||||
|
// missing. Does NOT auto-install and does NOT mutate user state.
|
||||||
|
val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
|
||||||
|
group = "chanora_bridge"
|
||||||
|
description = "SDD-118 item 9: verify cargo, cargo-ndk, and Android rustup targets are installed."
|
||||||
|
doLast {
|
||||||
|
// cargo presence
|
||||||
|
val cargoResult = providers.exec {
|
||||||
|
commandLine("cargo", "--version")
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.result.get()
|
||||||
|
if (cargoResult.exitValue != 0) {
|
||||||
|
throw GradleException(
|
||||||
|
"[SDD-118] `cargo` is not on PATH. Install Rust via https://rustup.rs " +
|
||||||
|
"and ensure ~/.cargo/bin is on PATH."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cargo-ndk presence
|
||||||
|
val cargoNdkResult = providers.exec {
|
||||||
|
commandLine("cargo", "ndk", "--version")
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.result.get()
|
||||||
|
if (cargoNdkResult.exitValue != 0) {
|
||||||
|
throw GradleException(
|
||||||
|
"[SDD-118] `cargo-ndk` is required. Install: cargo install cargo-ndk"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rustup targets
|
||||||
|
val rustupOutput = providers.exec {
|
||||||
|
commandLine("rustup", "target", "list", "--installed")
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.standardOutput.asText.get()
|
||||||
|
val requiredTriples = abiToRustTriple.values.toSet()
|
||||||
|
val installed = rustupOutput.lines().map { it.trim() }.filter { it.isNotEmpty() }.toSet()
|
||||||
|
val missing = requiredTriples - installed
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
throw GradleException(
|
||||||
|
"[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
|
||||||
|
"Install: rustup target add aarch64-linux-android x86_64-linux-android"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-118 item 13 (corrected): one cargo-ndk invocation per ABI rather
|
||||||
|
// than a single multi-`-t` invocation. The single-shot form is shorter
|
||||||
|
// but leaks ANDROID_ABI state across iterations, causing audiopus_sys's
|
||||||
|
// child CMake invocation for x86_64-linux-android to collide with the
|
||||||
|
// NDK toolchain file's armv7 default. Per-ABI invocation gives
|
||||||
|
// cargo-ndk a clean ANDROID_ABI per call and isolates failure scope to
|
||||||
|
// a single ABI.
|
||||||
|
//
|
||||||
|
// SDD-118 item 4: profile mapping — Gradle `debug` invokes `cargo build`
|
||||||
|
// (no `--release`); Gradle `release` invokes `cargo build --release`. We
|
||||||
|
// register one aggregate task per profile that fans out to one Exec
|
||||||
|
// sub-task per (profile, ABI) tuple.
|
||||||
|
fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
|
||||||
|
val capitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
// PascalCase ABI suffix for sub-task names (e.g. arm64-v8a -> Arm64V8a).
|
||||||
|
fun abiToTaskSuffix(abi: String): String =
|
||||||
|
abi.split('-', '_').joinToString("") { part ->
|
||||||
|
part.replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-118 item 5: shared env-var setup. Resolve NDK / CMake toolchain
|
||||||
|
// path once at configuration time; applied identically to every per-ABI
|
||||||
|
// sub-task below.
|
||||||
|
val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
|
||||||
|
.takeUnless { it.isNullOrBlank() }
|
||||||
|
?: android.ndkDirectory.absolutePath
|
||||||
|
val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
|
||||||
|
val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
|
||||||
|
val rustupBinDir = listOf(
|
||||||
|
"$homeDir/.cargo/bin",
|
||||||
|
"/opt/homebrew/opt/rustup/bin",
|
||||||
|
"/usr/local/opt/rustup/bin"
|
||||||
|
).firstOrNull { File(it).resolve("cargo").exists() }
|
||||||
|
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
|
||||||
|
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
|
||||||
|
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
|
||||||
|
|
||||||
|
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
|
||||||
|
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
|
||||||
|
// cleanly for both the Rust compile and any child CMake invocation.
|
||||||
|
val perAbiTaskProviders = configuredAbis.map { abi ->
|
||||||
|
val triple = abiToRustTriple.getValue(abi)
|
||||||
|
val abiSuffix = abiToTaskSuffix(abi)
|
||||||
|
tasks.register<Exec>("buildRustBridge$capitalized$abiSuffix") {
|
||||||
|
group = "chanora_bridge"
|
||||||
|
description = "SDD-118 items 1/4/13 (corrected): build chanora_bridge cdylib (profile=$profile, abi=$abi) via cargo-ndk."
|
||||||
|
dependsOn(checkRustBridgeToolchain)
|
||||||
|
|
||||||
|
// SDD-118 item 2: cargo workspace root is the repo root.
|
||||||
|
workingDir = cargoWorkspaceRoot
|
||||||
|
|
||||||
|
// SDD-118 item 13 (corrected): single `-t <abi>` per invocation.
|
||||||
|
val profileArgs = if (profile == "release") listOf("--release") else emptyList()
|
||||||
|
val cmd = mutableListOf("cargo", "ndk", "-t", abi)
|
||||||
|
cmd.add("--platform")
|
||||||
|
cmd.add(chanoraAndroidMinSdk.get())
|
||||||
|
cmd.add("--")
|
||||||
|
cmd.add("build")
|
||||||
|
cmd.addAll(profileArgs)
|
||||||
|
cmd.add("-p")
|
||||||
|
cmd.add("chanora_bridge")
|
||||||
|
commandLine(cmd)
|
||||||
|
|
||||||
|
// SDD-118 item 5 (corrected v3): completely replace the inherited
|
||||||
|
// environment with a deliberately-minimized map. The Gradle daemon
|
||||||
|
// accumulates env state from prior per-ABI cargo-ndk invocations
|
||||||
|
// (CFLAGS, CC, AR, CMAKE_*, RUSTFLAGS); audiopus_sys's cmake crate
|
||||||
|
// reads these before falling through to the NDK toolchain file's
|
||||||
|
// per-target settings, causing cross-ABI flag contamination (notably
|
||||||
|
// armv7's --target=armv7-none-linux-androideabi21 + -march=armv7-a
|
||||||
|
// leaks into the x86_64 task's clang command line). Resetting the
|
||||||
|
// env per task isolates each ABI from the rest.
|
||||||
|
//
|
||||||
|
// Deliberately NOT propagated (cross-ABI contamination vectors):
|
||||||
|
// CFLAGS, CXXFLAGS, CPPFLAGS, LDFLAGS, ASFLAGS
|
||||||
|
// CC, CXX, AR, AS, LD, NM, RANLIB, STRIP, OBJCOPY, OBJDUMP
|
||||||
|
// CMAKE_C_FLAGS, CMAKE_CXX_FLAGS, CMAKE_C_COMPILER,
|
||||||
|
// CMAKE_CXX_COMPILER, CMAKE_AR, CMAKE_LINKER, CMAKE_* (other)
|
||||||
|
// RUSTFLAGS
|
||||||
|
// Any per-target *_aarch64_linux_android,
|
||||||
|
// *_armv7_linux_androideabi, *_x86_64_linux_android,
|
||||||
|
// *_i686_linux_android variants of the above
|
||||||
|
// Any CARGO_TARGET_* env vars set by an outer build wrapper
|
||||||
|
val cleanEnv = buildMap<String, String> {
|
||||||
|
// Pass-through from the calling environment, only the safe set.
|
||||||
|
System.getenv("PATH")?.let { path ->
|
||||||
|
put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
|
||||||
|
}
|
||||||
|
put("HOME", homeDir)
|
||||||
|
System.getenv("USER")?.let { put("USER", it) }
|
||||||
|
System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
|
||||||
|
System.getenv("TEMP")?.let { put("TEMP", it) }
|
||||||
|
System.getenv("LANG")?.let { put("LANG", it) }
|
||||||
|
System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
|
||||||
|
put("CARGO_HOME", cargoHome)
|
||||||
|
put("RUSTUP_HOME", rustupHome)
|
||||||
|
put("RUSTUP_TOOLCHAIN", rustupToolchain)
|
||||||
|
System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
|
||||||
|
// Per-task explicit settings (override any pass-through).
|
||||||
|
// SDD-118 item 5: audiopus_sys needs libopus built from source
|
||||||
|
// statically because the NDK sysroot ships none.
|
||||||
|
put("LIBOPUS_STATIC", "1")
|
||||||
|
put("LIBOPUS_NO_PKG", "1")
|
||||||
|
put("CMAKE_POLICY_VERSION_MINIMUM", "3.5")
|
||||||
|
put("ANDROID_NDK_HOME", effectiveNdkPath)
|
||||||
|
put("ANDROID_NDK_ROOT", effectiveNdkPath)
|
||||||
|
// SDD-118 item 5 (extended): pin the CMake toolchain file so
|
||||||
|
// child CMake invocations spawned by build scripts (notably
|
||||||
|
// audiopus_sys via the `cmake` crate) can locate the Android NDK
|
||||||
|
// toolchain file at $NDK_HOME/build/cmake/android.toolchain.cmake.
|
||||||
|
put("CMAKE_TOOLCHAIN_FILE", cmakeToolchainFile)
|
||||||
|
// SDD-118 item 13 (corrected) — explicitly pin ANDROID_ABI and
|
||||||
|
// ANDROID_PLATFORM for this invocation. cargo-ndk already sets
|
||||||
|
// ANDROID_ABI per `-t` flag, but setting it ourselves forecloses
|
||||||
|
// any chance of a stale value leaking into audiopus_sys's child
|
||||||
|
// CMake process (the original single-shot bug).
|
||||||
|
put("ANDROID_ABI", abi)
|
||||||
|
put("ANDROID_PLATFORM", "android-${chanoraAndroidMinSdk.get()}")
|
||||||
|
}
|
||||||
|
// REPLACES the inherited environment (does not augment it).
|
||||||
|
environment = cleanEnv
|
||||||
|
|
||||||
|
// SDD-118 item 8: Gradle up-to-date semantics. Rust source tree,
|
||||||
|
// workspace manifest, and lockfile are inputs. The single per-ABI
|
||||||
|
// target/.so is the output of this Exec; the jniLibs copy is a
|
||||||
|
// separate task with its own outputs (item 7 below).
|
||||||
|
inputs.dir(cargoWorkspaceRoot.resolve("crates"))
|
||||||
|
inputs.file(cargoWorkspaceRoot.resolve("Cargo.toml"))
|
||||||
|
inputs.file(cargoWorkspaceRoot.resolve("Cargo.lock"))
|
||||||
|
inputs.property("profile", profile)
|
||||||
|
inputs.property("minSdk", chanoraAndroidMinSdk)
|
||||||
|
inputs.property("abi", abi)
|
||||||
|
outputs.file(cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-118 item 13 (corrected): aggregate no-op task. Downstream tasks
|
||||||
|
// (the copy task, the merge<Variant>JniLibFolders hook) continue to
|
||||||
|
// depend on `buildRustBridge${Profile}` as before; Gradle's dependsOn
|
||||||
|
// graph transitively pulls in every per-ABI sub-task. Per-ABI tasks are
|
||||||
|
// independent and can fail in isolation; Gradle may also choose to run
|
||||||
|
// them in parallel via its worker pool.
|
||||||
|
return tasks.register("buildRustBridge$capitalized") {
|
||||||
|
group = "chanora_bridge"
|
||||||
|
description = "SDD-118 items 1/4/13 (corrected): aggregate chanora_bridge build (profile=$profile) — fans out to per-ABI sub-tasks."
|
||||||
|
dependsOn(perAbiTaskProviders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buildRustBridgeDebug = registerCargoNdkBuildTask("debug")
|
||||||
|
val buildRustBridgeRelease = registerCargoNdkBuildTask("release")
|
||||||
|
|
||||||
|
// SDD-118 item 6: per-ABI plain copy (no symlinks, no fat binary) from
|
||||||
|
// target/<triple>/<profile>/libchanora_bridge.so into
|
||||||
|
// src/main/jniLibs/<abi>/libchanora_bridge.so. Overwrites prior staging.
|
||||||
|
//
|
||||||
|
// SDD-118 item 6 (extended): also stage libc++_shared.so from the
|
||||||
|
// NDK sysroot. libchanora_bridge.so is dynamically linked against
|
||||||
|
// the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
|
||||||
|
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
|
||||||
|
// the Android dynamic loader fails at first library load with
|
||||||
|
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
|
||||||
|
//
|
||||||
|
// SDD-118 item 6 (extended): Android-ABI ↔ NDK-sysroot-triple map.
|
||||||
|
// Note this is NOT the same as `abiToRustTriple`: the NDK sysroot
|
||||||
|
// uses `arm-linux-androideabi` for 32-bit ARM whereas Rust uses
|
||||||
|
// `armv7-linux-androideabi`. The sysroot directory names are the
|
||||||
|
// authoritative source; verified by listing
|
||||||
|
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
|
||||||
|
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
|
||||||
|
"arm64-v8a" to "aarch64-linux-android",
|
||||||
|
"x86_64" to "x86_64-linux-android",
|
||||||
|
)
|
||||||
|
fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
|
||||||
|
val capitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
val buildTask = if (profile == "release") buildRustBridgeRelease else buildRustBridgeDebug
|
||||||
|
|
||||||
|
// SDD-118 item 6 (extended): resolve NDK path the same way the
|
||||||
|
// cargo-ndk task does (env override, fall back to AGP default).
|
||||||
|
val effectiveNdkPath = System.getenv("ANDROID_NDK_HOME")
|
||||||
|
.takeUnless { it.isNullOrBlank() }
|
||||||
|
?: android.ndkDirectory.absolutePath
|
||||||
|
|
||||||
|
return tasks.register("copyRustBridgeJniLibs$capitalized") {
|
||||||
|
group = "chanora_bridge"
|
||||||
|
description = "SDD-118 item 6 (extended): strip and stage per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) into src/main/jniLibs/."
|
||||||
|
dependsOn(buildTask)
|
||||||
|
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs").asFile
|
||||||
|
val llvmStrip = file(
|
||||||
|
"$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/bin/llvm-strip"
|
||||||
|
)
|
||||||
|
doFirst {
|
||||||
|
jniLibsDir
|
||||||
|
.listFiles()
|
||||||
|
?.filter { it.isDirectory && !configuredAbis.contains(it.name) }
|
||||||
|
?.forEach { staleDir ->
|
||||||
|
staleDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configuredAbis.forEach { abi ->
|
||||||
|
val triple = abiToRustTriple.getValue(abi)
|
||||||
|
val sysrootTriple = abiToNdkSysrootTriple.getValue(abi)
|
||||||
|
val bridgeSo = cargoWorkspaceRoot.resolve("target/$triple/$profile/libchanora_bridge.so")
|
||||||
|
val strippedBridgeSo = layout.buildDirectory
|
||||||
|
.file("intermediates/stripped_rust_jni/$profile/$abi/libchanora_bridge.so")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
// SDD-118 item 6 (extended): also stage libc++_shared.so from the
|
||||||
|
// NDK sysroot. libchanora_bridge.so is dynamically linked against
|
||||||
|
// the NDK's shared C++ runtime (via audiopus_sys / opus-cpp and
|
||||||
|
// oboe-sys); without libc++_shared.so co-located in jniLibs/<abi>/
|
||||||
|
// the Android dynamic loader fails at first library load with
|
||||||
|
// UnsatisfiedLinkError: cannot locate symbol "__cxa_pure_virtual".
|
||||||
|
val cxxSharedSo = file(
|
||||||
|
"$effectiveNdkPath/toolchains/llvm/prebuilt/$ndkHostTag/sysroot/usr/lib/$sysrootTriple/libc++_shared.so"
|
||||||
|
)
|
||||||
|
// SDD-118 item 8: input tracking for both staged sources so
|
||||||
|
// Gradle correctly invalidates when either changes (e.g. NDK
|
||||||
|
// version bump replacing libc++_shared.so).
|
||||||
|
inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
|
||||||
|
inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
|
||||||
|
inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
|
||||||
|
outputs.file(strippedBridgeSo)
|
||||||
|
outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
|
||||||
|
outputs.file(File(jniLibsDir, "$abi/libc++_shared.so"))
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
val abiDir = File(jniLibsDir, abi)
|
||||||
|
abiDir.mkdirs()
|
||||||
|
strippedBridgeSo.parentFile.mkdirs()
|
||||||
|
bridgeSo.copyTo(strippedBridgeSo, overwrite = true)
|
||||||
|
project.exec {
|
||||||
|
commandLine(
|
||||||
|
llvmStrip.absolutePath,
|
||||||
|
"--strip-debug",
|
||||||
|
strippedBridgeSo.absolutePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
strippedBridgeSo.copyTo(
|
||||||
|
File(abiDir, "libchanora_bridge.so"),
|
||||||
|
overwrite = true,
|
||||||
|
)
|
||||||
|
cxxSharedSo.copyTo(
|
||||||
|
File(abiDir, "libc++_shared.so"),
|
||||||
|
overwrite = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val copyRustBridgeJniLibsDebug = registerJniLibsCopyTask("debug")
|
||||||
|
val copyRustBridgeJniLibsRelease = registerJniLibsCopyTask("release")
|
||||||
|
|
||||||
|
// SDD-118 item 10: release-inspection assertion. After the copy step and
|
||||||
|
// before the AGP merge-JniLibFolders node, verify each expected .so exists
|
||||||
|
// at its destination and is larger than 1 KiB. Catches silent cargo-ndk
|
||||||
|
// task skips and empty-link-output regressions before they ship into an AAB.
|
||||||
|
fun registerAssertStagedTask(profile: String): TaskProvider<Task> {
|
||||||
|
val capitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
val copyTask = if (profile == "release") copyRustBridgeJniLibsRelease else copyRustBridgeJniLibsDebug
|
||||||
|
return tasks.register("assertRustBridgeStaged$capitalized") {
|
||||||
|
group = "chanora_bridge"
|
||||||
|
description = "SDD-118 item 10 (extended): assert per-ABI libchanora_bridge.so + libc++_shared.so (profile=$profile) are staged and >1 KiB."
|
||||||
|
dependsOn(copyTask)
|
||||||
|
doLast {
|
||||||
|
configuredAbis.forEach { abi ->
|
||||||
|
// SDD-118 item 10 (extended): assert both the bridge .so and
|
||||||
|
// the co-staged libc++_shared.so. Missing C++ runtime is the
|
||||||
|
// exact failure mode that produced the
|
||||||
|
// `cannot locate symbol "__cxa_pure_virtual"` cold-launch crash.
|
||||||
|
listOf("libchanora_bridge.so", "libc++_shared.so").forEach { soName ->
|
||||||
|
val staged = layout.projectDirectory
|
||||||
|
.file("src/main/jniLibs/$abi/$soName").asFile
|
||||||
|
if (!staged.exists()) {
|
||||||
|
throw GradleException(
|
||||||
|
"[SDD-118 item 10] Missing staged native library '$soName' for ABI '$abi' " +
|
||||||
|
"(profile=$profile): expected at ${staged.absolutePath}. " +
|
||||||
|
"Did cargo-ndk fail or silently skip, or is the NDK sysroot missing libc++_shared.so?"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (staged.length() <= 1024) {
|
||||||
|
throw GradleException(
|
||||||
|
"[SDD-118 item 10] Staged native library '$soName' for ABI '$abi' " +
|
||||||
|
"(profile=$profile) is trivially small (${staged.length()} bytes) " +
|
||||||
|
"at ${staged.absolutePath}. Suspected empty-link-output regression."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val assertRustBridgeStagedDebug = registerAssertStagedTask("debug")
|
||||||
|
val assertRustBridgeStagedRelease = registerAssertStagedTask("release")
|
||||||
|
|
||||||
|
// SDD-118 item 7: task-graph wiring. Attach the build+copy+assert chain to
|
||||||
|
// AGP's per-variant `merge<Variant>JniLibFolders` task. This is the correct
|
||||||
|
// graph node — earlier than preBuild would over-trigger (e.g. IDE sync),
|
||||||
|
// later would race AGP's jniLibs packaging.
|
||||||
|
tasks.matching {
|
||||||
|
it.name.startsWith("merge") && it.name.endsWith("JniLibFolders")
|
||||||
|
}.configureEach {
|
||||||
|
val lowerName = name.lowercase()
|
||||||
|
val isRelease = lowerName.contains("release")
|
||||||
|
dependsOn(
|
||||||
|
if (isRelease) assertRustBridgeStagedRelease else assertRustBridgeStagedDebug
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# SDD-trace: SDD-073 item 6 (R8 / ProGuard stance) + SDD-105 AndroidJniBootstrap.
|
||||||
|
#
|
||||||
|
# Release builds run with isMinifyEnabled = true and isShrinkResources = true
|
||||||
|
# (see app/build.gradle.kts). This file lists the minimum keep rules
|
||||||
|
# required so that R8 does not strip symbols reachable only from native
|
||||||
|
# code, JNI, or reflection.
|
||||||
|
#
|
||||||
|
# Scope of keeps:
|
||||||
|
# 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105).
|
||||||
|
# 2. Native methods (declared with the `native` keyword) anywhere in
|
||||||
|
# the app module — these are looked up by signature from C/Rust.
|
||||||
|
# 3. The Android voice foreground service (SDD-107) — referenced from
|
||||||
|
# the manifest by FQN and from JNI via static start/stop helpers.
|
||||||
|
# 4. The Android audio mode controller (SDD-108) — audio mode is
|
||||||
|
# managed in Rust via direct JNI (engine.rs), not a Kotlin class.
|
||||||
|
# 5. Kotlin metadata required by FRB-generated bindings (SDD-079).
|
||||||
|
|
||||||
|
# --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) -----------
|
||||||
|
# JNI surface — these classes are referenced by FQN from AndroidManifest.xml,
|
||||||
|
# from native code (Rust/JNI lookups), or from FRB-generated bindings, so R8
|
||||||
|
# must NOT rename/remove them. All other app.chanora.** classes are
|
||||||
|
# consumed only from Kotlin and remain eligible for R8 shrink/optimize.
|
||||||
|
-keep class app.chanora.chanora_flutter.MainActivity { *; }
|
||||||
|
-keep class app.chanora.chanora_flutter.ChanoraApplication { *; }
|
||||||
|
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
|
||||||
|
-keep class app.chanora.chanora_flutter.AndroidPermissionRequester { *; }
|
||||||
|
-keep class app.chanora.chanora_flutter.BackIntentBridge { *; }
|
||||||
|
# SDD-109 / SDD-110 / SDD-111: JNI-referenced voice controllers.
|
||||||
|
# Called from the Rust audio engine via JNI static methods.
|
||||||
|
-keep class app.chanora.chanora_flutter.AndroidAudioFocusController { *; }
|
||||||
|
-keep class app.chanora.chanora_flutter.AndroidBluetoothScoController { *; }
|
||||||
|
-keep class app.chanora.chanora_flutter.AndroidAudioLifecycleController { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# flutter_rust_bridge generated bindings (SDD-079 TypedBridgeFacade) — keep
|
||||||
|
# the public surface so R8 does not rename or remove the symbols invoked
|
||||||
|
# from the native side.
|
||||||
|
-keep class ** implements io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||||
|
-keepclassmembers class * {
|
||||||
|
@io.flutter.plugin.common.MethodChannel$MethodCallHandler *;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 2. Native methods (declared with `native` in Kotlin/Java) ---------------
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------
|
||||||
|
# Covered by the JNI-surface keep block above.
|
||||||
|
|
||||||
|
# --- 4. Android Audio Mode Controller (SDD-108) -------------------------
|
||||||
|
# SDD-108 in-call audio mode (AudioManager.setMode) is implemented in the
|
||||||
|
# Rust audio engine (crates/chanora_audio/src/engine.rs) via direct JNI,
|
||||||
|
# not through a Kotlin controller class. No keep rule is needed; the
|
||||||
|
# engine.rs android_set_audio_mode / android_get_audio_mode helpers
|
||||||
|
# resolve AudioManager at runtime via ndk_context.
|
||||||
|
|
||||||
|
# --- 5. Kotlin metadata + serialisation (SDD-079) ----------------------------
|
||||||
|
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Defer to Flutter's own keep rules for the embedding layer; the Flutter
|
||||||
|
# Gradle plugin contributes those automatically.
|
||||||
@@ -1,8 +1,75 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- SDD-trace: SRS-045 (protocol adapter dials TeamSpeak-compatible
|
||||||
|
server) / SAD-032 (protocol-adapter isolation). Also supports
|
||||||
|
SRS-130 network-failure error reporting. Required for the
|
||||||
|
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- SRS-100: Network diagnostics. Allows the core to query active
|
||||||
|
network type and state for the diagnostic export. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- SDD-115: Prevent CPU sleep during active voice sessions.
|
||||||
|
Keeps the audio pipeline running when the screen locks. -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- SRS-025: Haptic feedback on PTT key press. Enables
|
||||||
|
VibrationEffect for transmit-mode tactile confirmation. -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
|
||||||
|
Required for the audio engine (chanora_audio) to open the
|
||||||
|
capture stream for voice transmission. The runtime grant
|
||||||
|
must still be requested; this declaration only allows the
|
||||||
|
app to ask. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
|
||||||
|
Required on API 28+ to start a foreground service that keeps
|
||||||
|
the voice session alive while the UI is backgrounded. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 3).
|
||||||
|
Required on API 34+ when the foreground service declares
|
||||||
|
foregroundServiceType="microphone". Declared here for the
|
||||||
|
entire API ladder; the platform ignores it on older releases. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
|
||||||
|
Required on API 33+ to display the ongoing voice-session
|
||||||
|
notification. Runtime-requested via AndroidPermissionRequester
|
||||||
|
(SDD-106); denial does not block the service. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-108 AndroidAudioModeController.
|
||||||
|
Required for AudioManager.setMode(MODE_IN_COMMUNICATION) and
|
||||||
|
related in-call audio routing operations. -->
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
|
||||||
|
BLUETOOTH_ADMIN required for startBluetoothSco() / stopBluetoothSco()
|
||||||
|
on API 23-30. Superseded by BLUETOOTH_CONNECT on API 31+. -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
|
||||||
|
BLUETOOTH required to query BluetoothAdapter on API < 31. -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
|
<!-- SDD-trace: Android Oboe voice backend; supports SCO/BLE
|
||||||
|
headset routing on API 31+. Declared here so the platform
|
||||||
|
allows querying / connecting to bonded Bluetooth audio devices
|
||||||
|
for the voice session. -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
|
<!-- SDD-105: Application class loads chanora_bridge native library before MainActivity onCreate -->
|
||||||
<application
|
<application
|
||||||
android:label="chanora_flutter"
|
android:label="Chanora"
|
||||||
android:name="${applicationName}"
|
android:name="app.chanora.chanora_flutter.ChanoraApplication"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -25,6 +92,26 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 2).
|
||||||
|
Manifest declaration for the foreground service that owns
|
||||||
|
the Android voice-session lifecycle. The Kotlin class is
|
||||||
|
owned by Wave 2B-2 (apps/chanora_flutter/android/app/src/main/kotlin/).
|
||||||
|
android:foregroundServiceType="microphone" is required by
|
||||||
|
API 30+ to gate background microphone access; ignored on
|
||||||
|
API 28-29 where capture is permitted without it. -->
|
||||||
|
<service
|
||||||
|
android:name="app.chanora.chanora_flutter.AndroidVoiceForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
|
<!-- flutter_foreground_task: keeps the Flutter engine alive when
|
||||||
|
connected to a server so voice chat is not killed in background. -->
|
||||||
|
<service
|
||||||
|
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages Android audio focus (requestAudioFocus / abandonAudioFocus) for
|
||||||
|
* the Chanora voice session.
|
||||||
|
*
|
||||||
|
* Trace: SDD-109 (Android Audio Focus)
|
||||||
|
*
|
||||||
|
* ## Lifecycle
|
||||||
|
*
|
||||||
|
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||||
|
* voice streams are opened. Requests `AUDIOFOCUS_GAIN` for
|
||||||
|
* `USAGE_VOICE_COMMUNICATION` / `CONTENT_TYPE_SPEECH` and registers
|
||||||
|
* an `OnAudioFocusChangeListener`.
|
||||||
|
* 2. Focus-change callbacks are forwarded to the Rust engine via
|
||||||
|
* `publishFocusChange(int)`, a JNI function declared in
|
||||||
|
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||||
|
* 3. [stop] — called by the Rust engine on voice stop. Abandons focus
|
||||||
|
* and clears the listener.
|
||||||
|
*
|
||||||
|
* ## Thread model
|
||||||
|
*
|
||||||
|
* `start` / `stop` are called from a tokio worker thread (via JNI), not
|
||||||
|
* the Android main thread. The `AudioManager` API is thread-safe.
|
||||||
|
* `OnAudioFocusChangeListener` callbacks arrive on the main thread; we
|
||||||
|
* forward to Rust via JNI which attaches the calling thread to the JVM.
|
||||||
|
*/
|
||||||
|
internal class AndroidAudioFocusController {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChanoraAudioFocus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI entry point implemented in
|
||||||
|
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||||
|
*
|
||||||
|
* Kotlin calls this from [OnAudioFocusChangeListener] to forward
|
||||||
|
* the focus-change integer to the Rust engine's BackendEvent channel.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
external fun publishFocusChange(state: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start audio focus management.
|
||||||
|
*
|
||||||
|
* Called from Rust via JNI after voice unit start.
|
||||||
|
* Idempotent: repeated calls against an already-started instance
|
||||||
|
* are silently ignored.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (focusRequested) {
|
||||||
|
Log.d(TAG, "start() called but focus already held; no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestFocus(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop audio focus management.
|
||||||
|
*
|
||||||
|
* Called from Rust via JNI on voice stop.
|
||||||
|
* Idempotent: safe to call when no focus is held.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun stop(context: Context) {
|
||||||
|
if (!focusRequested) {
|
||||||
|
Log.d(TAG, "stop() called but no focus held; no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abandonFocus(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var focusRequested: Boolean = false
|
||||||
|
private var focusRequestHandle: AudioFocusRequest? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||||
|
Log.i(TAG, "onAudioFocusChange: $focusChange")
|
||||||
|
try {
|
||||||
|
publishFocusChange(focusChange)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "publishFocusChange JNI failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestFocus(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
if (am == null) {
|
||||||
|
Log.e(TAG, "AudioManager unavailable; cannot request audio focus")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val attr = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(attr)
|
||||||
|
.setOnAudioFocusChangeListener(audioFocusListener, mainHandler)
|
||||||
|
.build()
|
||||||
|
val result = am.requestAudioFocus(request)
|
||||||
|
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
|
if (focusRequested) {
|
||||||
|
focusRequestHandle = request
|
||||||
|
}
|
||||||
|
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val result = am.requestAudioFocus(
|
||||||
|
audioFocusListener,
|
||||||
|
AudioManager.STREAM_VOICE_CALL,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN,
|
||||||
|
)
|
||||||
|
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
|
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "requestAudioFocus denied by platform: ${e.message}", e)
|
||||||
|
focusRequested = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonFocus(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
if (am == null) {
|
||||||
|
Log.e(TAG, "AudioManager unavailable; cannot abandon audio focus")
|
||||||
|
focusRequested = false
|
||||||
|
focusRequestHandle = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val handle = focusRequestHandle
|
||||||
|
focusRequestHandle = null
|
||||||
|
if (handle != null) {
|
||||||
|
am.abandonAudioFocusRequest(handle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(audioFocusListener)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "abandonAudioFocus dispatched")
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "abandonAudioFocus failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
focusRequested = false
|
||||||
|
focusRequestHandle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android audio lifecycle controller.
|
||||||
|
*
|
||||||
|
* Mirrors the iOS `AppDelegate` audio lifecycle events — route changes,
|
||||||
|
* device connectivity changes, and app lifecycle transitions — and
|
||||||
|
* forwards them to the Dart layer over a MethodChannel. The Dart side
|
||||||
|
* dispatches these to the Rust bridge, the same pattern as
|
||||||
|
* `_wireIosAudioLifecycle()` in `main.dart`.
|
||||||
|
*
|
||||||
|
* Trace: SDD-111 (MobileVoiceAudioBackend cross-platform)
|
||||||
|
*
|
||||||
|
* ## Events published
|
||||||
|
*
|
||||||
|
* `routeChange` — audio device connected/disconnected (payload:
|
||||||
|
* `{"routeType": String}`). Mimics iOS
|
||||||
|
* `handleRouteChange`.
|
||||||
|
* `interruptionBegan` — audio interruption started (e.g. phone call).
|
||||||
|
* `interruptionEnded` — audio interruption ended, with `shouldResume`.
|
||||||
|
* `appDidEnterBackground` — app moved to background.
|
||||||
|
* `appWillEnterForeground` — app returned to foreground.
|
||||||
|
* `mediaServicesReset` — equivalent of iOS media services reset
|
||||||
|
* (Android: audio devices changed significantly).
|
||||||
|
*
|
||||||
|
* ## Thread model
|
||||||
|
*
|
||||||
|
* All callbacks from `AudioDeviceCallback` arrive on the main thread
|
||||||
|
* (registered with the main `Handler`). App lifecycle observation is
|
||||||
|
* driven by the Flutter `AppLifecycleListener` on the Dart side;
|
||||||
|
* this controller only owns the audio-device side. The Flutter side
|
||||||
|
* is responsible for wiring lifecycle and forwarding events to Rust.
|
||||||
|
*/
|
||||||
|
internal class AndroidAudioLifecycleController(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
private val audioManager: AudioManager =
|
||||||
|
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var callbackRegistered = false
|
||||||
|
|
||||||
|
private var currentRouteFingerprint: String? = null
|
||||||
|
|
||||||
|
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||||
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
notifyRouteChange(addedDevices.firstOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
notifyRouteChange(removedDevices.firstOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channel: MethodChannel? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to [flutterEngine]'s binary messenger.
|
||||||
|
*
|
||||||
|
* Creates a MethodChannel named `chanora/android_audio_lifecycle`
|
||||||
|
* and starts observing audio device changes.
|
||||||
|
*/
|
||||||
|
fun attach(flutterEngine: FlutterEngine) {
|
||||||
|
channel = MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
)
|
||||||
|
startObservingAudioDevices()
|
||||||
|
Log.i(TAG, "attached to flutter engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach from the Flutter engine and stop observing.
|
||||||
|
*/
|
||||||
|
fun detach() {
|
||||||
|
stopObservingAudioDevices()
|
||||||
|
channel?.setMethodCallHandler(null)
|
||||||
|
channel = null
|
||||||
|
Log.i(TAG, "detached")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call from `Activity.onResume` to re-evaluate the current route.
|
||||||
|
*/
|
||||||
|
fun onResume() {
|
||||||
|
notifyRouteChange(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call from `Activity.onDestroy` to tear down.
|
||||||
|
*/
|
||||||
|
fun onDestroy() {
|
||||||
|
detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startObservingAudioDevices() {
|
||||||
|
if (callbackRegistered) return
|
||||||
|
audioManager.registerAudioDeviceCallback(audioDeviceCallback, mainHandler)
|
||||||
|
callbackRegistered = true
|
||||||
|
Log.d(TAG, "audio device observer started")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopObservingAudioDevices() {
|
||||||
|
if (!callbackRegistered) return
|
||||||
|
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||||
|
callbackRegistered = false
|
||||||
|
Log.d(TAG, "audio device observer stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyRouteChange(
|
||||||
|
device: AudioDeviceInfo?,
|
||||||
|
) {
|
||||||
|
val routeType = classifyCurrentRoute(device)
|
||||||
|
val routeFingerprint = buildRouteFingerprint(routeType)
|
||||||
|
if (routeFingerprint == currentRouteFingerprint) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentRouteFingerprint = routeFingerprint
|
||||||
|
Log.i(TAG, "route changed to: $routeType fingerprint=$routeFingerprint")
|
||||||
|
channel?.invokeMethod(
|
||||||
|
"handleRouteChange",
|
||||||
|
mapOf("routeType" to routeType),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRouteFingerprint(routeType: String): String {
|
||||||
|
val selectedCommunicationId =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManager.communicationDevice?.id?.toString().orEmpty()
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val inputs = audioManager
|
||||||
|
.getDevices(AudioManager.GET_DEVICES_INPUTS)
|
||||||
|
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
|
||||||
|
.sorted()
|
||||||
|
.joinToString("|")
|
||||||
|
val outputs = audioManager
|
||||||
|
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
.map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" }
|
||||||
|
.sorted()
|
||||||
|
.joinToString("|")
|
||||||
|
return "$routeType#$selectedCommunicationId#$inputs#$outputs"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify the current audio output route into a stable string
|
||||||
|
* matching the iOS route-classification schema so the Dart-side
|
||||||
|
* parser (`_parseBridgeAudioRoute`) works identically across
|
||||||
|
* platforms.
|
||||||
|
*/
|
||||||
|
private fun classifyCurrentRoute(specificDevice: AudioDeviceInfo?): String {
|
||||||
|
// If a specific device was added/removed, prefer its type.
|
||||||
|
if (specificDevice != null) {
|
||||||
|
return classifyDevice(specificDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, classify based on the current output devices.
|
||||||
|
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
if (outputs.isEmpty()) return "Unknown"
|
||||||
|
|
||||||
|
// Prefer wired/Bluetooth headset if connected.
|
||||||
|
for (d in outputs) {
|
||||||
|
val t = classifyDevice(d)
|
||||||
|
when (t) {
|
||||||
|
"WiredHeadset", "BluetoothHfp", "BluetoothA2dp", "UsbHeadset" -> return t
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to the first output device classification.
|
||||||
|
val first = classifyDevice(outputs.first())
|
||||||
|
return when (first) {
|
||||||
|
"Speaker" -> "Speaker"
|
||||||
|
"Earpiece" -> "Earpiece"
|
||||||
|
else -> "Speaker" // Default to Speaker for unknown outputs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun classifyDevice(d: AudioDeviceInfo): String = when (d.type) {
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Earpiece"
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADSET,
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WiredHeadset"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothHfp"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2dp"
|
||||||
|
AudioDeviceInfo.TYPE_BLE_HEADSET,
|
||||||
|
AudioDeviceInfo.TYPE_BLE_SPEAKER -> if (Build.VERSION.SDK_INT >= 31) "BluetoothHfp" else "BluetoothA2dp"
|
||||||
|
AudioDeviceInfo.TYPE_USB_HEADSET -> "UsbHeadset"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI -> "Hdmi"
|
||||||
|
else -> {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
d.type == AudioDeviceInfo.TYPE_BLE_BROADCAST
|
||||||
|
) {
|
||||||
|
"BluetoothA2dp"
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChanoraAudioLifecycle"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MethodChannel name for Android audio lifecycle events.
|
||||||
|
* Mirrors `chanora/ios_audio_lifecycle` on iOS.
|
||||||
|
*/
|
||||||
|
const val CHANNEL_NAME: String = "chanora/android_audio_lifecycle"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
internal class AndroidAudioOutputController(context: Context) :
|
||||||
|
MethodChannel.MethodCallHandler,
|
||||||
|
EventChannel.StreamHandler {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val audioManager: AudioManager = appContext.getSystemService(AudioManager::class.java)
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
private var callbackRegistered = false
|
||||||
|
|
||||||
|
private val deviceCallback = object : AudioDeviceCallback() {
|
||||||
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
emitDeviceChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
emitDeviceChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
try {
|
||||||
|
when (call.method) {
|
||||||
|
MethodChannels.METHOD_GET_OUTPUT_DEVICES -> result.success(getOutputDevices())
|
||||||
|
MethodChannels.METHOD_GET_COMMUNICATION_DEVICES -> result.success(getCommunicationDevices())
|
||||||
|
MethodChannels.METHOD_SET_COMMUNICATION_DEVICE -> {
|
||||||
|
val deviceId = call.argument<String>("deviceId")
|
||||||
|
if (deviceId.isNullOrBlank()) {
|
||||||
|
result.error("missing_device_id", "deviceId is required", null)
|
||||||
|
} else {
|
||||||
|
result.success(setCommunicationDevice(deviceId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MethodChannels.METHOD_CLEAR_COMMUNICATION_DEVICE -> {
|
||||||
|
clearCommunicationDevice()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
result.error("audio_output_failed", t.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
eventSink = events
|
||||||
|
startObserveAudioDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
stopObserveAudioDevices()
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach() {
|
||||||
|
stopObserveAudioDevices()
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOutputDevices(): List<Map<String, Any>> {
|
||||||
|
val communicationDevices = communicationDevices()
|
||||||
|
val communicationIds = CommunicationDeviceIds(
|
||||||
|
selectedId = selectedCommunicationDeviceId(),
|
||||||
|
availableIds = communicationDevices.map { it.id.toString() }.toSet(),
|
||||||
|
availableTypes = communicationDevices.map { it.type }.toSet(),
|
||||||
|
)
|
||||||
|
return audioManager
|
||||||
|
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
.map { device ->
|
||||||
|
val dto = device.toDto(communicationIds.selectedId, communicationIds.availableIds)
|
||||||
|
dto + ("isAvailableForCommunication" to (
|
||||||
|
dto["isAvailableForCommunication"] == true ||
|
||||||
|
communicationIds.availableTypes.contains(device.type)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCommunicationDevices(): List<Map<String, Any>> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val selectedId = selectedCommunicationDeviceId()
|
||||||
|
val devices = communicationDevices()
|
||||||
|
val availableIds = devices.map { it.id.toString() }.toSet()
|
||||||
|
return devices.map { it.toDto(selectedId, availableIds) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioManager
|
||||||
|
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
.map { it.toDto(selectedId = null, communicationIds = emptySet()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCommunicationDevice(deviceId: String): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
Log.w(TAG, "setCommunicationDevice unsupported below API 31 deviceId=$deviceId")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val communicationDevices = communicationDevices()
|
||||||
|
val outputDevice = audioManager
|
||||||
|
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||||
|
.firstOrNull { it.id.toString() == deviceId }
|
||||||
|
val target = communicationDevices.firstOrNull { it.id.toString() == deviceId }
|
||||||
|
?: outputDevice?.let { output ->
|
||||||
|
communicationDevices.firstOrNull { it.type == output.type }
|
||||||
|
}
|
||||||
|
if (target == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"setCommunicationDevice target not available deviceId=$deviceId outputs=${audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).joinToString { it.logLabel() }} communication=${communicationDevices.joinToString { it.logLabel() }}",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (target.requiresBluetoothConnectPermission() &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
appContext,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"setCommunicationDevice blocked for bluetooth target=${target.logLabel()}: BLUETOOTH_CONNECT not granted",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val changed = audioManager.setCommunicationDevice(target)
|
||||||
|
Log.i(TAG, "setCommunicationDevice device=${target.logLabel()} changed=$changed selected=${audioManager.communicationDevice?.logLabel()}")
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearCommunicationDevice() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManager.clearCommunicationDevice()
|
||||||
|
Log.i(TAG, "clearCommunicationDevice selected=${audioManager.communicationDevice?.logLabel()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startObserveAudioDevices() {
|
||||||
|
if (callbackRegistered) return
|
||||||
|
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
|
||||||
|
callbackRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopObserveAudioDevices() {
|
||||||
|
if (!callbackRegistered) return
|
||||||
|
audioManager.unregisterAudioDeviceCallback(deviceCallback)
|
||||||
|
callbackRegistered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitDeviceChanged() {
|
||||||
|
mainHandler.post {
|
||||||
|
eventSink?.success(mapOf("type" to "audioDeviceChanged"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun communicationDeviceIds(): CommunicationDeviceIds {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return CommunicationDeviceIds(
|
||||||
|
selectedId = null,
|
||||||
|
availableIds = emptySet(),
|
||||||
|
availableTypes = emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val devices = communicationDevices()
|
||||||
|
return CommunicationDeviceIds(
|
||||||
|
selectedId = selectedCommunicationDeviceId(),
|
||||||
|
availableIds = devices.map { it.id.toString() }.toSet(),
|
||||||
|
availableTypes = devices.map { it.type }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectedCommunicationDeviceId(): String? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManager.communicationDevice?.id?.toString()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun communicationDevices(): List<AudioDeviceInfo> =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManager.availableCommunicationDevices
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.toDto(
|
||||||
|
selectedId: String?,
|
||||||
|
communicationIds: Set<String>,
|
||||||
|
): Map<String, Any> {
|
||||||
|
val id = this.id.toString()
|
||||||
|
val normalizedType = normalizedType()
|
||||||
|
return mapOf(
|
||||||
|
"id" to id,
|
||||||
|
"name" to displayName(normalizedType),
|
||||||
|
"type" to normalizedType,
|
||||||
|
"isSelected" to (selectedId == id),
|
||||||
|
"isAvailableForCommunication" to communicationIds.contains(id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.displayName(normalizedType: String): String {
|
||||||
|
val product = productName?.toString()?.trim().orEmpty()
|
||||||
|
if (product.isNotEmpty()) return product
|
||||||
|
return when (normalizedType) {
|
||||||
|
"speaker" -> "Speaker"
|
||||||
|
"earpiece" -> "Earpiece"
|
||||||
|
"wiredHeadset", "wiredHeadphones" -> "Wired Headset"
|
||||||
|
"bluetoothA2dp", "bluetoothSco", "bluetoothLe" -> "Bluetooth Headset"
|
||||||
|
"usbHeadset" -> "USB Headset"
|
||||||
|
"hdmi" -> "HDMI"
|
||||||
|
else -> "Other Device"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.normalizedType(): String = when (type) {
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "earpiece"
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wiredHeadset"
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "wiredHeadphones"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bluetoothA2dp"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bluetoothSco"
|
||||||
|
AudioDeviceInfo.TYPE_USB_HEADSET -> "usbHeadset"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI -> "hdmi"
|
||||||
|
else -> if (isBluetoothLe()) "bluetoothLe" else "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.isBluetoothLe(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
|
||||||
|
return type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
|
||||||
|
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
|
||||||
|
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.requiresBluetoothConnectPermission(): Boolean =
|
||||||
|
normalizedType() == "bluetoothA2dp" ||
|
||||||
|
normalizedType() == "bluetoothSco" ||
|
||||||
|
normalizedType() == "bluetoothLe"
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.logLabel(): String =
|
||||||
|
"id=$id type=${normalizedType()} product=${productName?.toString()?.trim().orEmpty()}"
|
||||||
|
|
||||||
|
private data class CommunicationDeviceIds(
|
||||||
|
val selectedId: String?,
|
||||||
|
val availableIds: Set<String>,
|
||||||
|
val availableTypes: Set<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ChanoraAudioOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages Android Bluetooth voice routing for the Chanora voice session.
|
||||||
|
*
|
||||||
|
* Trace: SDD-110 (Android Bluetooth SCO)
|
||||||
|
*
|
||||||
|
* On Android 13 / API 33 and newer, VoIP apps are expected to select a
|
||||||
|
* Bluetooth communication route with `AudioManager.setCommunicationDevice()`
|
||||||
|
* so BLE audio headsets are supported. On older Android releases we fall
|
||||||
|
* back to legacy SCO start / stop management.
|
||||||
|
*
|
||||||
|
* ## Lifecycle
|
||||||
|
*
|
||||||
|
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||||
|
* voice streams are opened. On API 33+ this prefers
|
||||||
|
* `AudioManager.setCommunicationDevice()` for Bluetooth communication
|
||||||
|
* devices. On older releases it calls `AudioManager.startBluetoothSco()`
|
||||||
|
* if a Bluetooth SCO-capable device is connected and registers a
|
||||||
|
* `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`.
|
||||||
|
* 2. Legacy SCO state changes, or synthetic connected/disconnected state
|
||||||
|
* changes for the API 33+ path, are forwarded to the Rust engine via
|
||||||
|
* `publishScoStateChange(int)`, a JNI function declared in
|
||||||
|
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||||
|
* 3. [stop] — called by the Rust engine on voice stop. Clears the selected
|
||||||
|
* communication device on API 33+ when it is one we selected, otherwise
|
||||||
|
* falls back to `AudioManager.stopBluetoothSco()` and receiver teardown.
|
||||||
|
*
|
||||||
|
* ## Thread model
|
||||||
|
*
|
||||||
|
* `start` / `stop` are called from a tokio worker thread (via JNI).
|
||||||
|
* `AudioManager.startBluetoothSco` is asynchronous on legacy devices —
|
||||||
|
* the platform responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives
|
||||||
|
* on the main thread via the `BroadcastReceiver`.
|
||||||
|
*/
|
||||||
|
internal class AndroidBluetoothScoController {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChanoraBluetoothSco"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI entry point implemented in
|
||||||
|
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||||
|
*
|
||||||
|
* Kotlin calls this from the SCO state `BroadcastReceiver` to
|
||||||
|
* forward the state integer to the Rust engine's BackendEvent
|
||||||
|
* channel.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
external fun publishScoStateChange(state: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Bluetooth SCO management.
|
||||||
|
*
|
||||||
|
* Called from Rust via JNI after voice unit start.
|
||||||
|
* Idempotent: repeated calls against an already-started instance
|
||||||
|
* are silently ignored.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun start(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
if (scoStarted) {
|
||||||
|
Log.d(TAG, "start() called but SCO already active; no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scoStarted = true
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (trySelectBluetoothCommunicationDevice(appContext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"No selectable Bluetooth communication device on API 33+; legacy SCO fallback is skipped",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registerScoReceiver(appContext)
|
||||||
|
tryStartSco(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Bluetooth SCO management.
|
||||||
|
*
|
||||||
|
* Called from Rust via JNI on voice stop.
|
||||||
|
* Idempotent: safe to call when SCO is not active.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
scoStarted = false
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
clearSelectedBluetoothCommunicationDevice(appContext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unregisterScoReceiver(appContext)
|
||||||
|
tryStopSco(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoStarted: Boolean = false
|
||||||
|
private var receiverRegistered: Boolean = false
|
||||||
|
private var selectedCommunicationDeviceId: Int? = null
|
||||||
|
|
||||||
|
private val scoReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) return
|
||||||
|
val state = intent.getIntExtra(
|
||||||
|
AudioManager.EXTRA_SCO_AUDIO_STATE,
|
||||||
|
AudioManager.SCO_AUDIO_STATE_ERROR,
|
||||||
|
)
|
||||||
|
val prevState = intent.getIntExtra(
|
||||||
|
AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"SCO state: ${scoStateName(state)} (prev: ${scoStateName(prevState)})",
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
publishScoStateChange(state)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerScoReceiver(context: Context) {
|
||||||
|
if (receiverRegistered) return
|
||||||
|
try {
|
||||||
|
val filter = IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(scoReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
|
context.registerReceiver(scoReceiver, filter)
|
||||||
|
}
|
||||||
|
receiverRegistered = true
|
||||||
|
Log.d(TAG, "SCO receiver registered")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to register SCO receiver: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterScoReceiver(context: Context) {
|
||||||
|
if (!receiverRegistered) return
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(scoReceiver)
|
||||||
|
receiverRegistered = false
|
||||||
|
Log.d(TAG, "SCO receiver unregistered")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// Already unregistered — ignore silently.
|
||||||
|
receiverRegistered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter?
|
||||||
|
get() = try {
|
||||||
|
BluetoothAdapter.getDefaultAdapter()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BluetoothAdapter unavailable: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBluetoothScoOn(am: AudioManager): Boolean = try {
|
||||||
|
am.isBluetoothScoOn
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "isBluetoothScoOn failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBluetoothScoAvailableOffCall(am: AudioManager): Boolean = try {
|
||||||
|
am.isBluetoothScoAvailableOffCall
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "isBluetoothScoAvailableOffCall failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryStartSco(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
if (am == null) {
|
||||||
|
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping SCO start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBluetoothScoOn(am)) {
|
||||||
|
Log.i(TAG, "SCO already on; no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val adapter = bluetoothAdapter
|
||||||
|
if (adapter == null || !adapter.isEnabled) {
|
||||||
|
Log.i(TAG, "Bluetooth not enabled; skipping SCO start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val scoAvailableOffCall = isBluetoothScoAvailableOffCall(am)
|
||||||
|
if (!scoAvailableOffCall) {
|
||||||
|
Log.i(TAG, "Bluetooth SCO not available off-call; skipping SCO start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasScoHeadset = try {
|
||||||
|
val state = adapter.getProfileConnectionState(BluetoothProfile.HEADSET)
|
||||||
|
state == BluetoothProfile.STATE_CONNECTED
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "getProfileConnectionState(HEADSET) failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasScoHeadset) {
|
||||||
|
Log.i(TAG, "No SCO-capable Bluetooth headset connected; skipping SCO start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
am.startBluetoothSco()
|
||||||
|
Log.i(TAG, "startBluetoothSco() dispatched")
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "startBluetoothSco denied: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trySelectBluetoothCommunicationDevice(context: Context): Boolean {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
if (am == null) {
|
||||||
|
Log.e(TAG, "AudioManager unavailable; cannot select communication device")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping bluetooth communication device selection")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val target = am.availableCommunicationDevices.firstOrNull { it.isBluetoothCommunicationDevice() }
|
||||||
|
if (target == null) {
|
||||||
|
Log.i(TAG, "No Bluetooth communication device available on API 33+")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val current = am.communicationDevice
|
||||||
|
if (current?.id == target.id) {
|
||||||
|
selectedCommunicationDeviceId = target.id
|
||||||
|
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
|
||||||
|
Log.i(TAG, "Bluetooth communication device already selected: ${target.logLabel()}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val changed = am.setCommunicationDevice(target)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"setCommunicationDevice bluetooth target=${target.logLabel()} changed=$changed selected=${am.communicationDevice?.logLabel()}",
|
||||||
|
)
|
||||||
|
if (changed) {
|
||||||
|
selectedCommunicationDeviceId = target.id
|
||||||
|
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED)
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearSelectedBluetoothCommunicationDevice(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
if (am == null) {
|
||||||
|
Log.e(TAG, "AudioManager unavailable; cannot clear communication device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selectedId = selectedCommunicationDeviceId
|
||||||
|
val current = am.communicationDevice
|
||||||
|
if (selectedId == null || current?.id != selectedId) {
|
||||||
|
selectedCommunicationDeviceId = null
|
||||||
|
Log.d(TAG, "No app-selected Bluetooth communication device to clear")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
am.clearCommunicationDevice()
|
||||||
|
selectedCommunicationDeviceId = null
|
||||||
|
publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_DISCONNECTED)
|
||||||
|
Log.i(TAG, "clearCommunicationDevice() dispatched for bluetooth route")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryStopSco(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||||
|
if (am == null) {
|
||||||
|
Log.e(TAG, "AudioManager unavailable; cannot stop SCO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isBluetoothScoOn(am)) {
|
||||||
|
Log.d(TAG, "SCO not on; no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
am.stopBluetoothSco()
|
||||||
|
Log.i(TAG, "stopBluetoothSco() dispatched")
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "stopBluetoothSco denied: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishSyntheticScoState(state: Int) {
|
||||||
|
try {
|
||||||
|
publishScoStateChange(state)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.isBluetoothCommunicationDevice(): Boolean =
|
||||||
|
type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
|
||||||
|
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
(type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
|
||||||
|
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
|
||||||
|
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)))
|
||||||
|
|
||||||
|
private fun AudioDeviceInfo.logLabel(): String =
|
||||||
|
"id=$id type=$type product=${productName?.toString()?.trim().orEmpty()}"
|
||||||
|
|
||||||
|
private fun scoStateName(state: Int): String = when (state) {
|
||||||
|
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
|
||||||
|
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
|
||||||
|
AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING"
|
||||||
|
AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR"
|
||||||
|
else -> "UNKNOWN($state)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android runtime-permission requester for the audio subsystem.
|
||||||
|
*
|
||||||
|
* Trace:
|
||||||
|
* - SDD-106 `AndroidPermissionRequester` (RECORD_AUDIO runtime flow with
|
||||||
|
* listen-only fallback, settings deep-link for permanent denial,
|
||||||
|
* revocation handling, bridge event surface).
|
||||||
|
* - SRS-209 (Android runtime permission UX).
|
||||||
|
* - SAD-085 (source SAD for this SDD unit).
|
||||||
|
*
|
||||||
|
* ## Why Activity-bound, not a global singleton
|
||||||
|
*
|
||||||
|
* Android runtime permission requests are inherently tied to a
|
||||||
|
* concrete [Activity] (the system dialog is hosted by the Activity and
|
||||||
|
* the result is delivered through `onRequestPermissionsResult`). A
|
||||||
|
* process-wide singleton would have to track which Activity is
|
||||||
|
* currently in the foreground and would race with configuration
|
||||||
|
* changes. Binding the requester to the host Activity ([MainActivity],
|
||||||
|
* owned by Wave 2B-4) keeps the lifecycle simple and audit-clear: one
|
||||||
|
* requester per Activity instance, no static state to leak across
|
||||||
|
* Activity recreation.
|
||||||
|
*
|
||||||
|
* ## Contract with the host Activity
|
||||||
|
*
|
||||||
|
* The host Activity MUST:
|
||||||
|
* 1. Construct one [AndroidPermissionRequester] in `onCreate`.
|
||||||
|
* 2. Forward `onRequestPermissionsResult` to
|
||||||
|
* [handleRequestPermissionsResult].
|
||||||
|
* 3. Call [onResume] from its `Activity.onResume` so mid-session
|
||||||
|
* revocation (per SDD-106 §4) is observed.
|
||||||
|
*
|
||||||
|
* The Dart-side wiring (publishing `BridgeEvent::PermissionState` over
|
||||||
|
* the bridge event stream, per SDD-106 §5) is delivered through the
|
||||||
|
* [MethodChannels.ANDROID_PERMISSIONS] channel. The actual MethodChannel
|
||||||
|
* handler is wired by [MainActivity] in a follow-up task; for now this
|
||||||
|
* class exposes [stateChangeListener] and the channel name constant.
|
||||||
|
*
|
||||||
|
* ## Thread model
|
||||||
|
*
|
||||||
|
* All public methods are expected to be called from the Android main
|
||||||
|
* thread (Activity lifecycle thread). The Rust audio engine queries
|
||||||
|
* cached state via a separate JNI helper (SDD-106 §7, out of scope for
|
||||||
|
* this Kotlin class) — this class does not directly expose state to
|
||||||
|
* other threads.
|
||||||
|
*/
|
||||||
|
class AndroidPermissionRequester {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discrete permission-state values surfaced to the host Activity
|
||||||
|
* and onward to the Rust bridge.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §5 (state machine), SRS-209.
|
||||||
|
*
|
||||||
|
* Note: SDD-106 §5 also lists `Undetermined` as a fourth state.
|
||||||
|
* That state is internal to the Rust-side cache (initial value
|
||||||
|
* before the first query); the Kotlin requester never emits it
|
||||||
|
* because every emission corresponds to a resolved
|
||||||
|
* `checkSelfPermission` result.
|
||||||
|
*/
|
||||||
|
sealed class PermissionState {
|
||||||
|
/** Permission granted by the user. */
|
||||||
|
object Granted : PermissionState()
|
||||||
|
/** Permission denied, but the user may still be re-prompted. */
|
||||||
|
object Denied : PermissionState()
|
||||||
|
/**
|
||||||
|
* Permission denied with "do not ask again" — Android will no
|
||||||
|
* longer show the system dialog. The UI must deep-link to app
|
||||||
|
* settings via [openAppSettings].
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §3.
|
||||||
|
*/
|
||||||
|
object PermanentlyDenied : PermissionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val TAG = "ChanoraPerm"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable request code for `RECORD_AUDIO`. Must remain stable
|
||||||
|
* across releases so that result dispatch in
|
||||||
|
* [handleRequestPermissionsResult] matches the request.
|
||||||
|
*/
|
||||||
|
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
|
||||||
|
private const val REQ_STARTUP_PERMISSIONS = 0x53544152 // "STAR"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SharedPreferences file backing the cross-process / cross-launch
|
||||||
|
* "has the user ever been asked for this permission?" flag.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
|
||||||
|
*/
|
||||||
|
private const val PREFS_FILE = "chanora_permissions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean key set to `true` the first time we invoke
|
||||||
|
* [ActivityCompat.requestPermissions] for `RECORD_AUDIO`.
|
||||||
|
*
|
||||||
|
* Contract (M-1 strict-review fix):
|
||||||
|
* - `false` (default) — the user has never been prompted in
|
||||||
|
* any prior process for `RECORD_AUDIO`. In this state,
|
||||||
|
* `shouldShowRequestPermissionRationale == false` means
|
||||||
|
* "fresh / never asked", NOT "permanently denied".
|
||||||
|
* - `true` — the user has been prompted at least once in
|
||||||
|
* some prior (or current) process. In this state,
|
||||||
|
* `shouldShowRequestPermissionRationale == false` after a
|
||||||
|
* non-granted result indicates "Do not ask again" /
|
||||||
|
* permanent denial.
|
||||||
|
*
|
||||||
|
* Persisting this across process death is what lets
|
||||||
|
* [onResume] and [handleRequestPermissionsResult] faithfully
|
||||||
|
* observe revocation per SDD-106 §4 even when Android killed
|
||||||
|
* and restarted the process during a settings round-trip.
|
||||||
|
*/
|
||||||
|
private const val KEY_RECORD_AUDIO_HAS_REQUESTED = "record_audio_has_requested"
|
||||||
|
private const val KEY_BLUETOOTH_CONNECT_HAS_REQUESTED = "bluetooth_connect_has_requested"
|
||||||
|
private const val KEY_POST_NOTIFICATIONS_HAS_REQUESTED = "post_notifications_has_requested"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-flight callback for the active permission request, if any.
|
||||||
|
* Cleared in [handleRequestPermissionsResult]. Single-flight is
|
||||||
|
* enforced by overwriting (the most recent caller wins); concurrent
|
||||||
|
* `voice_join` coalescing (SDD-106 §8) is owned by the Rust side.
|
||||||
|
*/
|
||||||
|
private var pendingCallback: ((PermissionState) -> Unit)? = null
|
||||||
|
private var pendingStartupCallback: ((Map<String, PermissionState>) -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional listener invoked on every resolved state change. The
|
||||||
|
* MainActivity wires this to a [io.flutter.plugin.common.MethodChannel]
|
||||||
|
* on [MethodChannels.ANDROID_PERMISSIONS] in a follow-up task.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §5.
|
||||||
|
*/
|
||||||
|
var stateChangeListener: ((permission: String, state: PermissionState) -> Unit)? = null
|
||||||
|
|
||||||
|
fun ensureStartupPermissions(
|
||||||
|
activity: Activity,
|
||||||
|
callback: (Map<String, PermissionState>) -> Unit,
|
||||||
|
) {
|
||||||
|
val required = mutableListOf<String>()
|
||||||
|
val resolved = mutableMapOf<String, PermissionState>()
|
||||||
|
|
||||||
|
fun resolveNow(permission: String) {
|
||||||
|
resolved[permission] = currentState(activity, permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
val startupPermissions = buildList {
|
||||||
|
add(Manifest.permission.RECORD_AUDIO)
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
|
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (permission in startupPermissions) {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
resolveNow(permission)
|
||||||
|
} else {
|
||||||
|
markRequested(activity, permission)
|
||||||
|
required.add(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (required.isEmpty()) {
|
||||||
|
for (permission in startupPermissions) {
|
||||||
|
emit(permission, resolved[permission] ?: currentState(activity, permission), null)
|
||||||
|
}
|
||||||
|
callback(
|
||||||
|
startupPermissions.associateWith { resolved[it] ?: currentState(activity, it) }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingStartupCallback = callback
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
required.toTypedArray(),
|
||||||
|
REQ_STARTUP_PERMISSIONS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure `RECORD_AUDIO` is granted, prompting the user if not.
|
||||||
|
*
|
||||||
|
* Behaviour matrix (SDD-106 §1–§3):
|
||||||
|
* - Already granted → [callback] invoked synchronously with
|
||||||
|
* [PermissionState.Granted].
|
||||||
|
* - Not granted, may prompt → system dialog shown; result
|
||||||
|
* delivered asynchronously via
|
||||||
|
* [handleRequestPermissionsResult].
|
||||||
|
* - Permanently denied → caller is expected to surface
|
||||||
|
* "Open settings" affordance and call [openAppSettings].
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §1, §2, §3.
|
||||||
|
*/
|
||||||
|
fun ensureRecordAudioPermission(
|
||||||
|
activity: Activity,
|
||||||
|
callback: (PermissionState) -> Unit,
|
||||||
|
) {
|
||||||
|
val permission = Manifest.permission.RECORD_AUDIO
|
||||||
|
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
emit(permission, PermissionState.Granted, callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stash the callback; result is dispatched in
|
||||||
|
// handleRequestPermissionsResult.
|
||||||
|
pendingCallback = callback
|
||||||
|
// SDD-106 §3, §4 (M-1 strict-review fix): record that the user
|
||||||
|
// has now been prompted at least once. This persists across
|
||||||
|
// process death so subsequent shouldShowRequestPermissionRationale
|
||||||
|
// == false readings can be classified as permanent denial rather
|
||||||
|
// than "never asked".
|
||||||
|
markRecordAudioRequested(activity)
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
arrayOf(permission),
|
||||||
|
REQ_RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwarded from `Activity.onRequestPermissionsResult`. Returns
|
||||||
|
* `true` if the result was consumed by this requester, `false`
|
||||||
|
* otherwise (so the caller can chain other requesters).
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §2, §3.
|
||||||
|
*/
|
||||||
|
fun handleRequestPermissionsResult(
|
||||||
|
activity: Activity,
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray,
|
||||||
|
): Boolean {
|
||||||
|
if (requestCode == REQ_STARTUP_PERMISSIONS) {
|
||||||
|
val cb = pendingStartupCallback
|
||||||
|
pendingStartupCallback = null
|
||||||
|
val resolved = mutableMapOf<String, PermissionState>()
|
||||||
|
permissions.forEachIndexed { index, permission ->
|
||||||
|
val state = if (index < grantResults.size &&
|
||||||
|
grantResults[index] == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
PermissionState.Granted
|
||||||
|
} else {
|
||||||
|
currentDeniedState(activity, permission)
|
||||||
|
}
|
||||||
|
resolved[permission] = state
|
||||||
|
emit(permission, state, null)
|
||||||
|
}
|
||||||
|
cb?.invoke(resolved)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (requestCode != REQ_RECORD_AUDIO) return false
|
||||||
|
val cb = pendingCallback
|
||||||
|
pendingCallback = null
|
||||||
|
|
||||||
|
val idx = permissions.indexOf(Manifest.permission.RECORD_AUDIO)
|
||||||
|
if (idx < 0 || idx >= grantResults.size) {
|
||||||
|
// Edge case: user dismissed dialog without a result (e.g.
|
||||||
|
// tap outside on some OEMs). Treat as Denied (re-promptable).
|
||||||
|
Log.w(TAG, "RECORD_AUDIO result missing from callback; treating as Denied")
|
||||||
|
emit(Manifest.permission.RECORD_AUDIO, PermissionState.Denied, cb)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: PermissionState = if (grantResults[idx] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
PermissionState.Granted
|
||||||
|
} else {
|
||||||
|
// Per SDD-106 §3 (M-1 strict-review fix): distinguish
|
||||||
|
// "permanently denied" via shouldShowRequestPermissionRationale
|
||||||
|
// == false AFTER a denial. Because we just returned from a
|
||||||
|
// system dialog, the persistent has-ever-requested flag is
|
||||||
|
// guaranteed true at this point; we still consult it for
|
||||||
|
// symmetry with onResume and to make the contract explicit.
|
||||||
|
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
activity,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
val hasEverRequested = hasEverRequestedRecordAudio(activity)
|
||||||
|
if (shouldRationale) {
|
||||||
|
PermissionState.Denied
|
||||||
|
} else if (hasEverRequested) {
|
||||||
|
PermissionState.PermanentlyDenied
|
||||||
|
} else {
|
||||||
|
// Defensive: should be unreachable because we set the
|
||||||
|
// flag immediately before requestPermissions, but if a
|
||||||
|
// host bypasses ensureRecordAudioPermission and forwards
|
||||||
|
// a result, treat the absence of prior ask as Denied
|
||||||
|
// rather than over-classifying as permanent.
|
||||||
|
PermissionState.Denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(Manifest.permission.RECORD_AUDIO, state, cb)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-check `RECORD_AUDIO` state on Activity resume.
|
||||||
|
*
|
||||||
|
* Contract: [MainActivity] (Wave 2B-4) MUST invoke this from its
|
||||||
|
* `onResume` so mid-session revocation (SDD-106 §4) — which Android
|
||||||
|
* may apply by killing/restarting the process — is observed and
|
||||||
|
* propagated to the Rust audio engine via [stateChangeListener].
|
||||||
|
*
|
||||||
|
* The active voice session (per SDD-094) is not torn down here;
|
||||||
|
* only the cached permission state is refreshed. Clamping
|
||||||
|
* `capture_active = false` in the listen-only path is the Rust
|
||||||
|
* audio engine's responsibility (SDD-106 §4, §6).
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §4.
|
||||||
|
*/
|
||||||
|
fun onResume(activity: Activity, callback: (PermissionState) -> Unit) {
|
||||||
|
val permission = Manifest.permission.RECORD_AUDIO
|
||||||
|
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
val state: PermissionState = if (granted) {
|
||||||
|
PermissionState.Granted
|
||||||
|
} else {
|
||||||
|
// SDD-106 §3, §4 (M-1 strict-review fix): distinguish three
|
||||||
|
// cases that all present as "not granted" on cold launch:
|
||||||
|
//
|
||||||
|
// (a) Permission was never asked in any prior process —
|
||||||
|
// persistent flag is false. Surface Denied (re-promptable);
|
||||||
|
// UI will trigger the system dialog on first use.
|
||||||
|
// (b) Permission was asked before and is currently denied
|
||||||
|
// but still re-promptable — shouldShowRequestPermissionRationale
|
||||||
|
// returns true. Surface Denied.
|
||||||
|
// (c) Permission was asked before and the user selected
|
||||||
|
// "Don't ask again" or revoked from Settings — flag is
|
||||||
|
// true AND shouldShowRequestPermissionRationale is false.
|
||||||
|
// Surface PermanentlyDenied so the UI can deep-link to
|
||||||
|
// app settings (SDD-106 §3).
|
||||||
|
//
|
||||||
|
// Without the persistent flag, cases (a) and (c) are
|
||||||
|
// indistinguishable after a process restart, which is what
|
||||||
|
// the original implementation conservatively folded into
|
||||||
|
// Denied at the cost of misreporting revocation. The
|
||||||
|
// SharedPreferences-backed flag closes that gap.
|
||||||
|
val hasEverRequested = hasEverRequestedRecordAudio(activity)
|
||||||
|
if (!hasEverRequested) {
|
||||||
|
PermissionState.Denied
|
||||||
|
} else {
|
||||||
|
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
activity,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
if (shouldRationale) PermissionState.Denied else PermissionState.PermanentlyDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(permission, state, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentState(activity: Activity, permission: String): PermissionState {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) return PermissionState.Granted
|
||||||
|
return currentDeniedState(activity, permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-link to the application's Settings → App info page so the
|
||||||
|
* user can re-grant a permanently-denied permission.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §3.
|
||||||
|
*/
|
||||||
|
fun openAppSettings(activity: Activity) {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", activity.packageName, null)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
activity.startActivity(intent)
|
||||||
|
} catch (e: android.content.ActivityNotFoundException) {
|
||||||
|
// Surface for diagnostics; we do not silently swallow.
|
||||||
|
Log.e(TAG, "Failed to launch app settings: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a resolved state to both the per-request callback and the listener. */
|
||||||
|
private fun emit(
|
||||||
|
permission: String,
|
||||||
|
state: PermissionState,
|
||||||
|
callback: ((PermissionState) -> Unit)?,
|
||||||
|
) {
|
||||||
|
callback?.invoke(state)
|
||||||
|
// The stateChangeListener is wired by MainActivity to invoke
|
||||||
|
// METHOD_PERMISSION_STATE_CHANGED on ANDROID_PERMISSIONS, which
|
||||||
|
// publishes BridgeEvent::PermissionState to the bridge event
|
||||||
|
// stream. See MainActivity.configureFlutterEngine (SDD-106 §5).
|
||||||
|
stateChangeListener?.invoke(permission, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the persistent "has the user ever been asked for RECORD_AUDIO?"
|
||||||
|
* flag. See [KEY_RECORD_AUDIO_HAS_REQUESTED] for the contract.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
|
||||||
|
*/
|
||||||
|
private fun hasEverRequestedRecordAudio(activity: Activity): Boolean {
|
||||||
|
val prefs = activity.applicationContext.getSharedPreferences(
|
||||||
|
PREFS_FILE,
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
return prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasEverRequested(activity: Activity, permission: String): Boolean {
|
||||||
|
val prefs = activity.applicationContext.getSharedPreferences(
|
||||||
|
PREFS_FILE,
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
return prefs.getBoolean(requestedKey(permission), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist that the user has now been prompted for RECORD_AUDIO at
|
||||||
|
* least once. Idempotent. Uses `apply` (async, lossless across
|
||||||
|
* process death once committed) since the flag is consulted on
|
||||||
|
* subsequent launches, not in the same critical section.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §3, §4 (M-1 strict-review fix).
|
||||||
|
*/
|
||||||
|
private fun markRecordAudioRequested(activity: Activity) {
|
||||||
|
val prefs = activity.applicationContext.getSharedPreferences(
|
||||||
|
PREFS_FILE,
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
if (!prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)) {
|
||||||
|
prefs.edit().putBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, true).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markRequested(activity: Activity, permission: String) {
|
||||||
|
val prefs = activity.applicationContext.getSharedPreferences(
|
||||||
|
PREFS_FILE,
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
val key = requestedKey(permission)
|
||||||
|
if (!prefs.getBoolean(key, false)) {
|
||||||
|
prefs.edit().putBoolean(key, true).apply()
|
||||||
|
}
|
||||||
|
if (permission == Manifest.permission.RECORD_AUDIO) {
|
||||||
|
markRecordAudioRequested(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestedKey(permission: String): String = when (permission) {
|
||||||
|
Manifest.permission.RECORD_AUDIO -> KEY_RECORD_AUDIO_HAS_REQUESTED
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT -> KEY_BLUETOOTH_CONNECT_HAS_REQUESTED
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS -> KEY_POST_NOTIFICATIONS_HAS_REQUESTED
|
||||||
|
else -> "requested_${permission.replace('.', '_')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentDeniedState(activity: Activity, permission: String): PermissionState {
|
||||||
|
val hasEverRequested = hasEverRequested(activity, permission)
|
||||||
|
if (!hasEverRequested) return PermissionState.Denied
|
||||||
|
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
activity,
|
||||||
|
permission,
|
||||||
|
)
|
||||||
|
return if (shouldRationale) {
|
||||||
|
PermissionState.Denied
|
||||||
|
} else {
|
||||||
|
PermissionState.PermanentlyDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android foreground service that hosts the lifecycle of an active
|
||||||
|
* Chanora voice session.
|
||||||
|
*
|
||||||
|
* Trace:
|
||||||
|
* - SDD-107 `AndroidVoiceForegroundService` (foreground service class,
|
||||||
|
* notification channel id `chanora.voice.session`,
|
||||||
|
* `foregroundServiceType=microphone` on API 30+, POST_NOTIFICATIONS
|
||||||
|
* on API 33+, START_NOT_STICKY, ongoing-notification re-post on
|
||||||
|
* dismissal, lifecycle bound to voice_join / voice_leave /
|
||||||
|
* shutdown_if_idle).
|
||||||
|
* - SDD-105 (`AndroidJniBootstrap` — `JavaVM*` capture; the Rust side
|
||||||
|
* invokes [start] / [stop] via JNI per SDD-107 §10).
|
||||||
|
* - SDD-106 (`AndroidPermissionRequester` — service may start in
|
||||||
|
* listen-only mode; type=microphone is still declared so capture can
|
||||||
|
* resume on grant without a service restart, per SDD-107 §8).
|
||||||
|
* - SDD-108 (`AndroidAudioModeController` — owns
|
||||||
|
* `AudioManager.setMode`; this service does NOT touch the audio
|
||||||
|
* mode, per SDD-107 §8 and SDD-108 §1).
|
||||||
|
* - SAD-086 (source SAD).
|
||||||
|
*
|
||||||
|
* ## Naming
|
||||||
|
*
|
||||||
|
* The SDD-107 implementation text references the simple name
|
||||||
|
* `ChanoraVoiceForegroundService` in a `voice/` subpackage. This file
|
||||||
|
* uses the FQN `app.chanora.chanora_flutter.AndroidVoiceForegroundService`
|
||||||
|
* as coordinated for Wave 2B (the AndroidManifest entry declared by
|
||||||
|
* Wave 2B-1 matches this FQN). The behaviour and lifecycle contract
|
||||||
|
* are unchanged.
|
||||||
|
*
|
||||||
|
* ## Responsibilities
|
||||||
|
*
|
||||||
|
* This service is purely the foreground-lifecycle host: it keeps the
|
||||||
|
* process foregrounded so the Rust audio engine (`crates/chanora_audio`)
|
||||||
|
* can keep capture / playback streams open while the UI is backgrounded.
|
||||||
|
*
|
||||||
|
* It does NOT:
|
||||||
|
* - call `AudioManager.setMode` (owned by SDD-108 /
|
||||||
|
* `AndroidAudioModeController`),
|
||||||
|
* - open or drive audio streams (owned by the Rust audio engine
|
||||||
|
* via existing JNI on `crates/chanora_audio`),
|
||||||
|
* - perform any networking.
|
||||||
|
*/
|
||||||
|
class AndroidVoiceForegroundService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChanoraVoiceFGS"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification channel id.
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §4.
|
||||||
|
*/
|
||||||
|
private const val CHANNEL_ID = "chanora.voice.session"
|
||||||
|
|
||||||
|
// SDD-107 §4: notification strings are sourced from
|
||||||
|
// res/values/strings.xml and accessed via the service context
|
||||||
|
// in instance methods below (ensureChannel, buildNotification).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable notification id ("CHAN") per SDD-107 §5. Must remain
|
||||||
|
* stable so that re-posts after user dismissal land on the
|
||||||
|
* same notification slot.
|
||||||
|
*/
|
||||||
|
private const val NOTIFICATION_ID = 0x4348414E
|
||||||
|
|
||||||
|
/** Intent action: begin / refresh the foreground session. */
|
||||||
|
const val ACTION_START_VOICE_SESSION: String =
|
||||||
|
"app.chanora.action.START_VOICE_SESSION"
|
||||||
|
|
||||||
|
/** Intent action: terminate the foreground session. */
|
||||||
|
const val ACTION_STOP_VOICE_SESSION: String =
|
||||||
|
"app.chanora.action.STOP_VOICE_SESSION"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the service in voice-session mode.
|
||||||
|
*
|
||||||
|
* Intended call sites:
|
||||||
|
* - Kotlin: [MainActivity] or platform glue.
|
||||||
|
* - Rust: invoked from the `voice_join` bridge handler via
|
||||||
|
* JNI per SDD-107 §10 (the `JavaVM*` captured by SDD-105
|
||||||
|
* is used to call this static method).
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §7 (lifecycle — start).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply {
|
||||||
|
action = ACTION_START_VOICE_SESSION
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the service. Idempotent per SDD-107 §7.
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §7 (lifecycle — stop).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, AndroidVoiceForegroundService::class.java).apply {
|
||||||
|
action = ACTION_STOP_VOICE_SESSION
|
||||||
|
}
|
||||||
|
// We deliberately route through startService so the running
|
||||||
|
// service receives ACTION_STOP_VOICE_SESSION via
|
||||||
|
// onStartCommand and can perform an orderly stopForeground +
|
||||||
|
// stopSelf. If the service is already stopped this is a
|
||||||
|
// no-op aside from a brief onCreate/onDestroy cycle, which
|
||||||
|
// satisfies the idempotent-stop contract.
|
||||||
|
try {
|
||||||
|
context.startService(intent)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
// Background-start restrictions: if the app is in a
|
||||||
|
// state that disallows starting services (e.g.,
|
||||||
|
// process being torn down), there is nothing left to
|
||||||
|
// stop. Log and continue.
|
||||||
|
Log.w(TAG, "stop() could not deliver intent: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service is start-only — no clients bind. Per SDD-107 §1.
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §1.
|
||||||
|
*/
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle entry. Promotes the service to foreground within the
|
||||||
|
* 5-second platform deadline per SDD-107 §5, dispatches on the
|
||||||
|
* incoming action, and returns [START_NOT_STICKY] per SDD-107 §7
|
||||||
|
* (process death must NOT auto-restart; state is rebuilt from the
|
||||||
|
* audio engine on the next `voice_join`).
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §5, §7.
|
||||||
|
*/
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP_VOICE_SESSION -> {
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
// Treat null action (e.g., service re-creation by the
|
||||||
|
// system before we return START_NOT_STICKY takes effect)
|
||||||
|
// and any unknown action the same as START — we must call
|
||||||
|
// startForeground within 5 seconds of onStartCommand or the
|
||||||
|
// platform will kill us with a ForegroundServiceDidNotStart
|
||||||
|
// exception (Android 12+).
|
||||||
|
ACTION_START_VOICE_SESSION, null -> promoteToForeground()
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "Unknown action: ${intent.action}; treating as START")
|
||||||
|
promoteToForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SDD-107 §7: do not auto-restart on process death; the next
|
||||||
|
// voice_join re-starts the service explicitly.
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear-down. Removes the ongoing notification and clears the
|
||||||
|
* channel slot per SDD-107 §7 (stop path).
|
||||||
|
*
|
||||||
|
* If the user manually dismissed the notification while the
|
||||||
|
* service was still running (API 34+ allows this per SDD-107 §7),
|
||||||
|
* the dismissal does NOT stop the session — the audio engine is
|
||||||
|
* the sole authority for when the service goes away. The service
|
||||||
|
* re-posts the notification on the next state transition; in
|
||||||
|
* practice "next state transition" means the next [start] call
|
||||||
|
* arriving with ACTION_START_VOICE_SESSION, which calls
|
||||||
|
* [startForeground] again. We do NOT schedule a JobScheduler /
|
||||||
|
* AlarmManager re-post (per the task scope) — re-posting is
|
||||||
|
* driven by the Rust audio engine emitting a state tick.
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §7.
|
||||||
|
*/
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopForegroundCompat()
|
||||||
|
try {
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
|
nm?.cancel(NOTIFICATION_ID)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Unlikely on cancel(), but POST_NOTIFICATIONS-related
|
||||||
|
// SecurityException surfaces have been reported on some
|
||||||
|
// OEM builds. Do not crash teardown.
|
||||||
|
Log.w(TAG, "cancel() raised SecurityException: ${e.message}")
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build (or refresh) the channel and call [startForeground].
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §4 (channel), §5 (notification), §6
|
||||||
|
* (POST_NOTIFICATIONS handling).
|
||||||
|
*/
|
||||||
|
private fun promoteToForeground() {
|
||||||
|
ensureChannel()
|
||||||
|
val notification = buildNotification()
|
||||||
|
|
||||||
|
// POST_NOTIFICATIONS (API 33+): per SDD-107 §6, denial must NOT
|
||||||
|
// block the service. We still call startForeground; the
|
||||||
|
// platform will silently suppress the notification if the
|
||||||
|
// permission is missing. We log a warning so this case is
|
||||||
|
// observable.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"POST_NOTIFICATIONS not granted; service will run without a " +
|
||||||
|
"visible notification (SDD-107 §6).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// Typed overload required on API 29+ when the manifest
|
||||||
|
// declares foregroundServiceType=microphone (SDD-107
|
||||||
|
// §2). On API 34+ this is enforced; declaring it on
|
||||||
|
// 29+ is forward-safe.
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// API 28 (minSdk per build.gradle.kts): un-typed
|
||||||
|
// startForeground is the only available overload.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// FOREGROUND_SERVICE_MICROPHONE missing on API 34+, or
|
||||||
|
// RECORD_AUDIO missing while type=microphone is declared.
|
||||||
|
// Per SDD-107 §6 / §7 we do not crash; the audio engine
|
||||||
|
// will observe the absence via the BridgeEvent stream and
|
||||||
|
// clamp to listen-only.
|
||||||
|
Log.e(
|
||||||
|
TAG,
|
||||||
|
"startForeground(type=MICROPHONE) failed: ${e.message}. " +
|
||||||
|
"Service may not have promoted; SDD-107 §6 listen-only path applies.",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the notification channel on first use. Repeated creates
|
||||||
|
* are no-ops per Android contract (SDD-107 §4).
|
||||||
|
*/
|
||||||
|
private fun ensureChannel() {
|
||||||
|
// minSdk = 28 (Android 9), so NotificationChannel APIs (O+) are
|
||||||
|
// unconditionally available.
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as? NotificationManager ?: run {
|
||||||
|
Log.e(TAG, "NotificationManager unavailable; cannot create channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val existing = nm.getNotificationChannel(CHANNEL_ID)
|
||||||
|
if (existing != null) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.voice_session_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.voice_session_channel_description)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ongoing notification.
|
||||||
|
*
|
||||||
|
* Privacy: per SDD-107 §5, the notification carries ONLY product
|
||||||
|
* copy — no server-supplied channel names, user names, or message
|
||||||
|
* content.
|
||||||
|
*
|
||||||
|
* Trace: SDD-107 §5.
|
||||||
|
*/
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val contentIntent: PendingIntent? = packageManager
|
||||||
|
.getLaunchIntentForPackage(packageName)
|
||||||
|
?.let { launch ->
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
launch,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(getString(R.string.voice_session_notification_title))
|
||||||
|
.setContentText(getString(R.string.voice_session_notification_text))
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setShowWhen(false)
|
||||||
|
|
||||||
|
if (contentIntent != null) {
|
||||||
|
builder.setContentIntent(contentIntent)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version-portable `stopForeground` that removes the notification.
|
||||||
|
*/
|
||||||
|
private fun stopForegroundCompat() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.window.OnBackInvokedCallback
|
||||||
|
import android.window.OnBackInvokedDispatcher
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackIntentBridge — Kotlin half of SDD-028 (`BackIntentService`).
|
||||||
|
*
|
||||||
|
* Trace: SDD-028 (Android back-intent registration paths + deterministic
|
||||||
|
* route-pop ordering) / SAD-018.
|
||||||
|
*
|
||||||
|
* Responsibility (Kotlin side):
|
||||||
|
* * On API 33+ (`Build.VERSION.SDK_INT >= TIRAMISU`) register an
|
||||||
|
* `OnBackInvokedCallback` against `activity.onBackInvokedDispatcher`
|
||||||
|
* at `PRIORITY_DEFAULT`.
|
||||||
|
* * On API < 33, register an `OnBackPressedCallback` (enabled = true)
|
||||||
|
* against `activity.onBackPressedDispatcher`.
|
||||||
|
* * Both callbacks consume the system back event (no super / no
|
||||||
|
* re-dispatch) and forward a single `backIntent` MethodChannel call
|
||||||
|
* to Dart with payload `{"kind": "system_back"}`. The Dart side
|
||||||
|
* (`BackIntentService`) owns the deterministic route-pop policy
|
||||||
|
* (PTT-active → ignore, modal → close, non-root → pop, root →
|
||||||
|
* `exitCandidate`).
|
||||||
|
* * When Dart concludes the event is unhandled at the root route, it
|
||||||
|
* calls back via `popToSystem`, which invokes `activity.finish()`
|
||||||
|
* exactly once (M-2 strict-review fix: guarded by `isFinishing` /
|
||||||
|
* `isDestroyed` so a duplicate Dart-side ExitApp decision cannot
|
||||||
|
* re-enter `finish()`).
|
||||||
|
*
|
||||||
|
* Threading: all callbacks are dispatched on the Android main thread,
|
||||||
|
* matching SDD-028 §4 ("dispatch runs on the platform main thread").
|
||||||
|
*
|
||||||
|
* ## Lifecycle / ownership (M-3 strict-review fix)
|
||||||
|
*
|
||||||
|
* Previously this type was a Kotlin `object` (process-wide singleton)
|
||||||
|
* that retained a strong reference to a `FlutterActivity`. Even though
|
||||||
|
* [detach] cleared the reference, the singleton pattern is an
|
||||||
|
* Activity-leak footgun: any caller forgetting `detach()` would pin
|
||||||
|
* the Activity for the lifetime of the process.
|
||||||
|
*
|
||||||
|
* The bridge is now a plain `class`. `MainActivity` constructs one
|
||||||
|
* instance in `configureFlutterEngine`, holds it in a private field,
|
||||||
|
* and clears the field in `onDestroy` after calling [detach]. The
|
||||||
|
* Activity is therefore reachable only via the bridge instance, and
|
||||||
|
* the bridge instance is reachable only via `MainActivity` — when
|
||||||
|
* `MainActivity` is destroyed, both become eligible for collection.
|
||||||
|
*/
|
||||||
|
class BackIntentBridge {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val TAG = "BackIntentBridge"
|
||||||
|
private const val KEY_KIND = "kind"
|
||||||
|
private const val VALUE_SYSTEM_BACK = "system_back"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channel: MethodChannel? = null
|
||||||
|
private var attachedActivity: FlutterActivity? = null
|
||||||
|
|
||||||
|
// API 33+ path.
|
||||||
|
private var onBackInvokedCallback: OnBackInvokedCallback? = null
|
||||||
|
|
||||||
|
// Pre-33 path.
|
||||||
|
private var onBackPressedCallback: OnBackPressedCallback? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the back-intent bridge to [activity] using [messenger] for
|
||||||
|
* the Dart `MethodChannel`. Idempotent: a second call detaches the
|
||||||
|
* prior attachment first.
|
||||||
|
*
|
||||||
|
* The [activity] reference is retained until [detach] is called.
|
||||||
|
* The owning `MainActivity` MUST invoke [detach] from its
|
||||||
|
* `onDestroy` (see class KDoc on lifecycle / ownership).
|
||||||
|
*/
|
||||||
|
fun attach(activity: FlutterActivity, messenger: BinaryMessenger) {
|
||||||
|
// Guard against double-attach (e.g. re-creation under config changes).
|
||||||
|
detach()
|
||||||
|
|
||||||
|
// X-2 strict-review fix: channel + method names sourced from the
|
||||||
|
// central MethodChannels registry rather than file-local literals.
|
||||||
|
val ch = MethodChannel(messenger, MethodChannels.BACK_INTENT)
|
||||||
|
channel = ch
|
||||||
|
attachedActivity = activity
|
||||||
|
|
||||||
|
// SDD-028: Dart -> Kotlin "popToSystem" closes the activity at root.
|
||||||
|
ch.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
MethodChannels.METHOD_POP_TO_SYSTEM -> {
|
||||||
|
// M-2 strict-review fix (SDD-028): guarantee exactly-once
|
||||||
|
// finish(). If Dart issues two ExitApp decisions in rapid
|
||||||
|
// succession we must not re-enter Activity teardown.
|
||||||
|
if (activity.isFinishing || activity.isDestroyed) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"popToSystem received but activity already finishing/destroyed; ignoring duplicate",
|
||||||
|
)
|
||||||
|
result.success(null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
activity.finish()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerApi33(activity, ch)
|
||||||
|
} else {
|
||||||
|
registerPre33(activity, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach from the previously-attached activity and tear down all
|
||||||
|
* registrations. Safe to call repeatedly.
|
||||||
|
*/
|
||||||
|
fun detach() {
|
||||||
|
val activity = attachedActivity
|
||||||
|
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
onBackInvokedCallback?.let { cb ->
|
||||||
|
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onBackInvokedCallback = null
|
||||||
|
|
||||||
|
onBackPressedCallback?.remove()
|
||||||
|
onBackPressedCallback = null
|
||||||
|
|
||||||
|
channel?.setMethodCallHandler(null)
|
||||||
|
channel = null
|
||||||
|
attachedActivity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerApi33(activity: FlutterActivity, channel: MethodChannel) {
|
||||||
|
val cb = OnBackInvokedCallback {
|
||||||
|
// SDD-028 §1: callback delegates to BackIntentService.dispatch()
|
||||||
|
// on the Dart side and does NOT call any system fallback.
|
||||||
|
channel.invokeMethod(
|
||||||
|
MethodChannels.METHOD_BACK_INTENT,
|
||||||
|
mapOf(KEY_KIND to VALUE_SYSTEM_BACK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
|
||||||
|
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
|
||||||
|
cb,
|
||||||
|
)
|
||||||
|
onBackInvokedCallback = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPre33(activity: FlutterActivity, channel: MethodChannel) {
|
||||||
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
// SDD-028 §1: forward to Dart; do NOT re-invoke the
|
||||||
|
// system fallback here. If Dart determines the event is
|
||||||
|
// unhandled (root route), it calls back via
|
||||||
|
// `popToSystem` which executes `activity.finish()`.
|
||||||
|
channel.invokeMethod(
|
||||||
|
MethodChannels.METHOD_BACK_INTENT,
|
||||||
|
mapOf(KEY_KIND to VALUE_SYSTEM_BACK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cast resolves onBackPressedDispatcher via ComponentActivity (FlutterActivity → FragmentActivity → ComponentActivity).
|
||||||
|
(activity as ComponentActivity).onBackPressedDispatcher.addCallback(activity, cb)
|
||||||
|
onBackPressedCallback = cb
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application subclass that owns the earliest-possible load of the
|
||||||
|
* `chanora_bridge` native cdylib.
|
||||||
|
*
|
||||||
|
* Trace: SDD-105 (AndroidJniBootstrap), DEC-004 (single JavaVM* capture point).
|
||||||
|
*
|
||||||
|
* Rationale (SDD-105):
|
||||||
|
* `JNI_OnLoad` in `crates/chanora_bridge/src/android_init.rs` captures the
|
||||||
|
* process-wide `JavaVM*` the first time the library is loaded. By performing
|
||||||
|
* the `System.loadLibrary("chanora_bridge")` call here in `Application.onCreate`
|
||||||
|
* we guarantee that the VM pointer is available *before* any Flutter plugin,
|
||||||
|
* background isolate, or FRB-generated stub attempts to call into Rust. This
|
||||||
|
* also ensures the load happens on the main thread, satisfying the threading
|
||||||
|
* guarantee documented in SDD-105.
|
||||||
|
*
|
||||||
|
* Manifest contract (Wave 2B-1):
|
||||||
|
* AndroidManifest.xml must reference this class via
|
||||||
|
* android:name="app.chanora.chanora_flutter.ChanoraApplication"
|
||||||
|
* in the <application> tag. Wave 2B-1 owns that edit; do not duplicate it
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
class ChanoraApplication : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// SDD-105: load the native bridge as early as possible so JNI_OnLoad
|
||||||
|
// runs before any FRB call site is reached.
|
||||||
|
try {
|
||||||
|
// SDD-105 implementation detail: the Android NDK C++ runtime
|
||||||
|
// (libc++_shared.so) must be loaded BEFORE chanora_bridge so that
|
||||||
|
// chanora_bridge's undefined C++ symbols (notably
|
||||||
|
// __cxa_pure_virtual, __cxa_atexit) resolve via the global
|
||||||
|
// symbol namespace. libchanora_bridge.so does not carry a
|
||||||
|
// DT_NEEDED libc++_shared.so entry today (the Rust cdylib build
|
||||||
|
// does not emit one), so the loader will not auto-pull it just
|
||||||
|
// because it is co-located in jniLibs/<abi>/. This explicit
|
||||||
|
// ordered loadLibrary pair is the canonical NDK pattern.
|
||||||
|
// Follow-up: a cleaner build-side fix is to inject
|
||||||
|
// `-lc++_shared` into the Rust cdylib link args via
|
||||||
|
// cargo:rustc-link-lib in a build.rs, producing a DT_NEEDED
|
||||||
|
// entry that makes this manual ordering unnecessary.
|
||||||
|
System.loadLibrary("c++_shared")
|
||||||
|
System.loadLibrary("chanora_bridge")
|
||||||
|
} catch (t: UnsatisfiedLinkError) {
|
||||||
|
// SDD-105: panic-safe FFI boundary — log loudly, then rethrow so
|
||||||
|
// the process fails fast rather than silently running without the
|
||||||
|
// Rust core. A swallowed link error would manifest much later as
|
||||||
|
// a confusing UnsatisfiedLinkError on the first FRB call.
|
||||||
|
Log.e(TAG, "Failed to load native library", t)
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChanoraApp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,314 @@
|
|||||||
package app.chanora.chanora_flutter
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import app.chanora.chanora_flutter.AndroidPermissionRequester
|
||||||
|
import app.chanora.chanora_flutter.BackIntentBridge
|
||||||
|
import app.chanora.chanora_flutter.MethodChannels
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
/**
|
||||||
|
* Host activity for the Chanora Flutter shell.
|
||||||
|
*
|
||||||
|
* Trace: SDD-105 (AndroidJniBootstrap), SDD-028 (Android lifecycle wiring),
|
||||||
|
* SDD-106 (AndroidPermissionRequester), DEC-004 (single JNI bootstrap path).
|
||||||
|
*
|
||||||
|
* Note (SDD-105):
|
||||||
|
* The `System.loadLibrary("chanora_bridge")` call previously lived in this
|
||||||
|
* activity's companion-object initialiser. It has been moved to
|
||||||
|
* [ChanoraApplication.onCreate] so the native library — and therefore
|
||||||
|
* `JNI_OnLoad`'s `JavaVM*` capture — is available before any plugin or
|
||||||
|
* background isolate touches the bridge. See ChanoraApplication.kt and
|
||||||
|
* the AndroidManifest <application android:name> declaration owned by
|
||||||
|
* Wave 2B-1.
|
||||||
|
*/
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* JNI entry point implemented in `chanora_bridge::android_init`.
|
||||||
|
* Initialises `ndk_context` with our Activity so the direct Oboe
|
||||||
|
* backend can find Android audio services when `chanora_audio`
|
||||||
|
* starts the capture / playback streams.
|
||||||
|
*
|
||||||
|
* Trace: SDD-105 (AndroidJniBootstrap). Signature must remain stable;
|
||||||
|
* the Rust side declares the matching `extern "system"` symbol.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
external fun initChanoraContext(context: Context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI entry point implemented in `chanora_bridge::permission_jni`.
|
||||||
|
* Forwards a resolved Android runtime-permission state into the
|
||||||
|
* Rust bridge, which (a) clamps the audio engine's transmit
|
||||||
|
* selector when `permission == "android.permission.RECORD_AUDIO"`
|
||||||
|
* and the state is anything other than `Granted`, and (b)
|
||||||
|
* broadcasts a `BridgeEvent::PermissionState` so the Dart UI
|
||||||
|
* observes the authoritative state alongside the existing
|
||||||
|
* MethodChannel.
|
||||||
|
*
|
||||||
|
* The Rust side wraps the body in `catch_unwind` so a panic in
|
||||||
|
* the bridge never unwinds into the JVM.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §5, §6; SRS-209. Signature must remain stable;
|
||||||
|
* the Rust side declares the matching `extern "system"` symbol
|
||||||
|
* `Java_app_chanora_chanora_1flutter_MainActivity_publishPermissionState`.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
external fun publishPermissionState(permission: String, state: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDD-106: Activity-bound permission requester. Nullable because it is
|
||||||
|
// only constructed once the FlutterEngine is configured; lifecycle
|
||||||
|
// callbacks (onResume / onRequestPermissionsResult) must null-guard.
|
||||||
|
private var permissionRequester: AndroidPermissionRequester? = null
|
||||||
|
|
||||||
|
// SDD-106: Retained so onResume / onDestroy can forward state changes
|
||||||
|
// to Dart on MethodChannels.ANDROID_PERMISSIONS.
|
||||||
|
private var permissionsChannel: MethodChannel? = null
|
||||||
|
private var audioOutputChannel: MethodChannel? = null
|
||||||
|
private var audioOutputEvents: EventChannel? = null
|
||||||
|
private var audioOutputController: AndroidAudioOutputController? = null
|
||||||
|
|
||||||
|
// SDD-111: Android audio lifecycle controller (route changes, device
|
||||||
|
// add/remove, app lifecycle). Mirrors the iOS
|
||||||
|
// `chanora/ios_audio_lifecycle` channel pattern.
|
||||||
|
private var audioLifecycleController: AndroidAudioLifecycleController? = null
|
||||||
|
|
||||||
|
// SDD-028 (M-3 strict-review fix): the back-intent bridge is owned
|
||||||
|
// by this Activity instance, not a process-wide `object`. Constructed
|
||||||
|
// in configureFlutterEngine and cleared in onDestroy after detach().
|
||||||
|
private var backIntentBridge: BackIntentBridge? = null
|
||||||
|
|
||||||
|
private fun permissionStateWireName(
|
||||||
|
state: AndroidPermissionRequester.PermissionState,
|
||||||
|
): String = when (state) {
|
||||||
|
AndroidPermissionRequester.PermissionState.Granted -> "Granted"
|
||||||
|
AndroidPermissionRequester.PermissionState.Denied -> "Denied"
|
||||||
|
AndroidPermissionRequester.PermissionState.PermanentlyDenied -> "PermanentlyDenied"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
window.navigationBarColor = Color.rgb(255, 251, 254)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
window.decorView.systemUiVisibility =
|
||||||
|
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||||
|
}
|
||||||
|
// SDD-105: pass the Application context to the Rust side so the
|
||||||
|
// audio engine can open device handles. Must run on the main thread
|
||||||
|
// before any `chanora_audio` call from Dart. The native library is
|
||||||
|
// already loaded by ChanoraApplication.onCreate at this point.
|
||||||
|
initChanoraContext(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
// SDD-028 / DEC-004: attach the Android -> Dart back-intent bridge to
|
||||||
|
// the FlutterEngine's binary messenger as soon as the engine is
|
||||||
|
// available. The bridge forwards hardware-back and intent-back events
|
||||||
|
// into Dart's navigation stack.
|
||||||
|
//
|
||||||
|
// SDD-028 (M-3 strict-review fix): BackIntentBridge is now a class
|
||||||
|
// instance owned by this Activity rather than a process-wide `object`,
|
||||||
|
// eliminating the singleton-Activity-leak footgun.
|
||||||
|
val bridge = BackIntentBridge()
|
||||||
|
bridge.attach(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||||
|
backIntentBridge = bridge
|
||||||
|
|
||||||
|
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
|
||||||
|
// Construct the permissions MethodChannel and the Activity-bound
|
||||||
|
// requester, then wire stateChangeListener to forward resolved
|
||||||
|
// PermissionState transitions to Dart.
|
||||||
|
val channel = MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
MethodChannels.ANDROID_PERMISSIONS,
|
||||||
|
)
|
||||||
|
permissionsChannel = channel
|
||||||
|
val requester = AndroidPermissionRequester()
|
||||||
|
requester.stateChangeListener = { permission, state ->
|
||||||
|
val stateName = permissionStateWireName(state)
|
||||||
|
channel.invokeMethod(
|
||||||
|
MethodChannels.METHOD_PERMISSION_STATE_CHANGED,
|
||||||
|
mapOf(
|
||||||
|
"permission" to permission,
|
||||||
|
"state" to stateName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// SDD-106 §5/§6: also forward the resolved state into the
|
||||||
|
// Rust bridge so `TransmitModeSelector` clamps the
|
||||||
|
// transmit gate authoritatively (independent of whether
|
||||||
|
// the Dart UI has re-rendered yet). The Rust side is
|
||||||
|
// panic-safe via `catch_unwind`; we still guard with
|
||||||
|
// try/catch here so a `UnsatisfiedLinkError` (e.g. an
|
||||||
|
// unexpected ABI mismatch) cannot crash MainActivity.
|
||||||
|
// Trace: SDD-106 §5, §6; SRS-209.
|
||||||
|
try {
|
||||||
|
publishPermissionState(permission, stateName)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"Chanora",
|
||||||
|
"publishPermissionState JNI hook failed: ${t.message}",
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissionRequester = requester
|
||||||
|
|
||||||
|
// SDD-106 §1, §3 (Dart-side integration follow-up): handle
|
||||||
|
// outbound Dart -> Kotlin calls so the Flutter UI can drive the
|
||||||
|
// runtime permission request and the settings deep-link. The
|
||||||
|
// resolved state is still delivered asynchronously via
|
||||||
|
// stateChangeListener -> METHOD_PERMISSION_STATE_CHANGED.
|
||||||
|
//
|
||||||
|
// Trace: SDD-106, SRS-209.
|
||||||
|
channel.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"requestRecordAudio" -> {
|
||||||
|
val r = permissionRequester
|
||||||
|
if (r != null) {
|
||||||
|
r.ensureRecordAudioPermission(this) { state ->
|
||||||
|
result.success(permissionStateWireName(state))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error(
|
||||||
|
"no_requester",
|
||||||
|
"AndroidPermissionRequester not bound",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"requestStartupPermissions" -> {
|
||||||
|
val r = permissionRequester
|
||||||
|
if (r != null) {
|
||||||
|
r.ensureStartupPermissions(this) { states ->
|
||||||
|
result.success(
|
||||||
|
states.mapValues { (_, state) -> permissionStateWireName(state) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error(
|
||||||
|
"no_requester",
|
||||||
|
"AndroidPermissionRequester not bound",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"openAppSettings" -> {
|
||||||
|
val r = permissionRequester
|
||||||
|
if (r != null) {
|
||||||
|
r.openAppSettings(this)
|
||||||
|
result.success(null)
|
||||||
|
} else {
|
||||||
|
result.error(
|
||||||
|
"no_requester",
|
||||||
|
"AndroidPermissionRequester not bound",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioOutputController = AndroidAudioOutputController(applicationContext)
|
||||||
|
this.audioOutputController = audioOutputController
|
||||||
|
val audioOutputChannel = MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
MethodChannels.AUDIO_OUTPUT,
|
||||||
|
)
|
||||||
|
this.audioOutputChannel = audioOutputChannel
|
||||||
|
audioOutputChannel.setMethodCallHandler(audioOutputController)
|
||||||
|
val audioOutputEvents = EventChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
MethodChannels.AUDIO_OUTPUT_EVENTS,
|
||||||
|
)
|
||||||
|
this.audioOutputEvents = audioOutputEvents
|
||||||
|
audioOutputEvents.setStreamHandler(audioOutputController)
|
||||||
|
|
||||||
|
// SDD-111: attach the Android audio lifecycle controller
|
||||||
|
// (route changes, device add/remove, interruptions).
|
||||||
|
val lifecycleController = AndroidAudioLifecycleController(applicationContext)
|
||||||
|
lifecycleController.attach(flutterEngine)
|
||||||
|
audioLifecycleController = lifecycleController
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// SDD-106: re-evaluate Android runtime permission state on resume and
|
||||||
|
// forward the result to Dart via MethodChannel
|
||||||
|
// "app.chanora/android_permissions". Must be null-safe: if the
|
||||||
|
// requester is not yet wired we no-op rather than crashing.
|
||||||
|
//
|
||||||
|
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
|
||||||
|
val requester = permissionRequester
|
||||||
|
if (requester != null) {
|
||||||
|
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
|
||||||
|
requester.onResume(this) { _ -> }
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
|
requester.stateChangeListener?.invoke(
|
||||||
|
android.Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
requester.currentState(this, android.Manifest.permission.BLUETOOTH_CONNECT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
requester.stateChangeListener?.invoke(
|
||||||
|
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
requester.currentState(this, android.Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SDD-111: re-evaluate the current audio route on resume.
|
||||||
|
audioLifecycleController?.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray,
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
// SDD-106: Wired in fast-builder follow-up; closes Wave 2B-4 coordination gap.
|
||||||
|
// Forward the system result into the Activity-bound requester so its
|
||||||
|
// pending callback resolves and stateChangeListener fires.
|
||||||
|
permissionRequester?.handleRequestPermissionsResult(
|
||||||
|
this,
|
||||||
|
requestCode,
|
||||||
|
permissions,
|
||||||
|
grantResults,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
// SDD-028: detach the back-intent bridge before the activity is torn
|
||||||
|
// down so the FlutterEngine's binary messenger isn't retained.
|
||||||
|
//
|
||||||
|
// SDD-028 (M-3 strict-review fix): drop the owning reference so the
|
||||||
|
// bridge instance — and the Activity it retains — become eligible
|
||||||
|
// for collection immediately after onDestroy.
|
||||||
|
backIntentBridge?.detach()
|
||||||
|
backIntentBridge = null
|
||||||
|
// SDD-106: drop the Dart->Kotlin handler before nilling the
|
||||||
|
// channel so a late invokeMethod from Dart cannot land on a
|
||||||
|
// dangling requester reference.
|
||||||
|
permissionsChannel?.setMethodCallHandler(null)
|
||||||
|
audioOutputChannel?.setMethodCallHandler(null)
|
||||||
|
audioOutputEvents?.setStreamHandler(null)
|
||||||
|
audioOutputController?.detach()
|
||||||
|
// SDD-111: detach the audio lifecycle controller.
|
||||||
|
audioLifecycleController?.onDestroy()
|
||||||
|
audioLifecycleController = null
|
||||||
|
permissionRequester = null
|
||||||
|
permissionsChannel = null
|
||||||
|
audioOutputChannel = null
|
||||||
|
audioOutputEvents = null
|
||||||
|
audioOutputController = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central registry of Flutter MethodChannel names used by the Android
|
||||||
|
* platform code.
|
||||||
|
*
|
||||||
|
* Trace:
|
||||||
|
* - SDD-106 (`AndroidPermissionRequester` — bridge event surface)
|
||||||
|
* - SRS-209 (Android runtime permission UX)
|
||||||
|
*
|
||||||
|
* The channels declared here are the Kotlin-side contract only. The
|
||||||
|
* Dart-side handlers that subscribe / dispatch on these channels are
|
||||||
|
* deliberately out of scope for the Wave 2B-2 implementation slice and
|
||||||
|
* are handed off to a follow-up task.
|
||||||
|
*/
|
||||||
|
internal object MethodChannels {
|
||||||
|
/**
|
||||||
|
* Channel for Android runtime-permission state events emitted by
|
||||||
|
* [AndroidPermissionRequester]. The Kotlin side invokes
|
||||||
|
* [METHOD_PERMISSION_STATE_CHANGED] whenever the resolved permission
|
||||||
|
* state transitions.
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §5 (Bridge event surface), SRS-209.
|
||||||
|
*/
|
||||||
|
const val ANDROID_PERMISSIONS: String = "app.chanora/android_permissions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method name invoked on [ANDROID_PERMISSIONS] when the resolved
|
||||||
|
* permission state for a tracked Android runtime permission
|
||||||
|
* changes. Arguments are a `Map<String, Any>` with keys:
|
||||||
|
* - "permission": String (Android permission constant, e.g.
|
||||||
|
* "android.permission.RECORD_AUDIO")
|
||||||
|
* - "state": String (one of "Granted", "Denied",
|
||||||
|
* "PermanentlyDenied")
|
||||||
|
*
|
||||||
|
* Trace: SDD-106 §5.
|
||||||
|
*/
|
||||||
|
const val METHOD_PERMISSION_STATE_CHANGED: String = "permissionStateChanged"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel name for the Android back-intent bridge (Kotlin <-> Dart).
|
||||||
|
*
|
||||||
|
* The Dart side uses the matching constant `backIntentChannelName` in
|
||||||
|
* `lib/services/back_intent_service.dart`; both must remain in sync.
|
||||||
|
*
|
||||||
|
* Trace: SDD-028 (BackIntentService), SAD-018. X-2 strict-review fix:
|
||||||
|
* consolidated from BackIntentBridge.kt's previous private literal.
|
||||||
|
*/
|
||||||
|
const val BACK_INTENT: String = "app.chanora/back_intent"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method invoked on [BACK_INTENT] from Kotlin -> Dart when a system
|
||||||
|
* back event fires. Payload: `{"kind": "system_back"}`.
|
||||||
|
*
|
||||||
|
* Trace: SDD-028 §1. X-2 strict-review fix.
|
||||||
|
*/
|
||||||
|
const val METHOD_BACK_INTENT: String = "backIntent"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method invoked on [BACK_INTENT] from Dart -> Kotlin when the Dart
|
||||||
|
* side concludes the back event is unhandled at the root route and
|
||||||
|
* the activity should finish.
|
||||||
|
*
|
||||||
|
* Trace: SDD-028 §1. X-2 strict-review fix.
|
||||||
|
*/
|
||||||
|
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel for Android audio lifecycle events (route changes,
|
||||||
|
* interruptions, app lifecycle). Mirrors the iOS
|
||||||
|
* `chanora/ios_audio_lifecycle` channel.
|
||||||
|
*
|
||||||
|
* Trace: SDD-111 (cross-platform mobile voice backend)
|
||||||
|
*/
|
||||||
|
const val ANDROID_AUDIO_LIFECYCLE: String = "chanora/android_audio_lifecycle"
|
||||||
|
|
||||||
|
const val AUDIO_OUTPUT: String = "app.audio_output"
|
||||||
|
const val AUDIO_OUTPUT_EVENTS: String = "app.audio_output/events"
|
||||||
|
const val METHOD_GET_OUTPUT_DEVICES: String = "getOutputDevices"
|
||||||
|
const val METHOD_GET_COMMUNICATION_DEVICES: String = "getCommunicationDevices"
|
||||||
|
const val METHOD_SET_COMMUNICATION_DEVICE: String = "setCommunicationDevice"
|
||||||
|
const val METHOD_CLEAR_COMMUNICATION_DEVICE: String = "clearCommunicationDevice"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- SDD-107 §4: Chinese (Simplified) localized notification strings.
|
||||||
|
Product-owned copy; no server-supplied content per SDD-107 §5. -->
|
||||||
|
<string name="voice_session_channel_name">语音会话</string>
|
||||||
|
<string name="voice_session_channel_description">Chanora 语音会话处于活动状态时显示。</string>
|
||||||
|
<string name="voice_session_notification_title">Chanora — 语音会话进行中</string>
|
||||||
|
<string name="voice_session_notification_text">麦克风可能正在使用中。</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- SDD-107 §4: Voice session foreground service notification strings.
|
||||||
|
These are product-owned strings; server-supplied content is never
|
||||||
|
placed in notification copy per SDD-107 §5 privacy note. -->
|
||||||
|
<string name="voice_session_channel_name">Voice session</string>
|
||||||
|
<string name="voice_session_channel_description">Shown while a Chanora voice session is active.</string>
|
||||||
|
<string name="voice_session_notification_title">Chanora — Voice session active</string>
|
||||||
|
<string name="voice_session_notification_text">Microphone may be in use.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store</string>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>uploadBitcode</key>
|
||||||
|
<false/>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Uncomment this line to define a global platform for your project
|
||||||
|
platform :ios, '16.0'
|
||||||
|
|
||||||
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|
||||||
|
project 'Runner', {
|
||||||
|
'Debug' => :debug,
|
||||||
|
'Profile' => :release,
|
||||||
|
'Release' => :release,
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutter_root
|
||||||
|
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||||
|
unless File.exist?(generated_xcode_build_settings_path)
|
||||||
|
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||||
|
end
|
||||||
|
|
||||||
|
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||||
|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||||
|
return matches[1].strip if matches
|
||||||
|
end
|
||||||
|
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||||
|
end
|
||||||
|
|
||||||
|
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||||
|
|
||||||
|
flutter_ios_podfile_setup
|
||||||
|
|
||||||
|
target 'Runner' do
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
# Chanora Rust bridge as a vendored framework. The podspec runs
|
||||||
|
# `cargo build --release --target aarch64-apple-ios` and wraps the
|
||||||
|
# produced dylib into chanora_bridge.framework. CocoaPods then
|
||||||
|
# integrates the framework into Runner.xcodeproj with the
|
||||||
|
# appropriate Embed & Sign build phase, so the Runner app ships
|
||||||
|
# with the bridge inside its Frameworks/ directory and
|
||||||
|
# flutter_rust_bridge can dlopen() it at runtime via FRB's
|
||||||
|
# default `chanora_bridge.framework/chanora_bridge` lookup path.
|
||||||
|
pod 'chanora_bridge', :path => '.'
|
||||||
|
|
||||||
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
|
target 'RunnerTests' do
|
||||||
|
inherit! :search_paths
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
flutter_additional_ios_build_settings(target)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
PODS:
|
||||||
|
- audio_session (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- chanora_bridge (1.0.0)
|
||||||
|
- connectivity_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- Flutter (1.0.0)
|
||||||
|
- flutter_foreground_task (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- haptic_kit (1.0.0):
|
||||||
|
- Flutter
|
||||||
|
- package_info_plus (0.4.5):
|
||||||
|
- Flutter
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- shared_preferences_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- url_launcher_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
|
- chanora_bridge (from `.`)
|
||||||
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
||||||
|
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
|
||||||
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
audio_session:
|
||||||
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
|
chanora_bridge:
|
||||||
|
:path: "."
|
||||||
|
connectivity_plus:
|
||||||
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
|
Flutter:
|
||||||
|
:path: Flutter
|
||||||
|
flutter_foreground_task:
|
||||||
|
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
||||||
|
haptic_kit:
|
||||||
|
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||||
|
package_info_plus:
|
||||||
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
|
share_plus:
|
||||||
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
|
chanora_bridge: 2ed7c2ba427fab135dd9eab66c507b09cfee113a
|
||||||
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||||
|
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||||
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: e2123068539aeb66d53dc1612b383d13f489ede2
|
||||||
|
|
||||||
|
COCOAPODS: 1.16.2
|
||||||
@@ -8,13 +8,18 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
|
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B4754099284EEDCD859A973 /* Pods_Runner.framework */; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
|
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */; };
|
||||||
|
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */ = {isa = PBXBuildFile; productRef = 8C5000032DD0000000000001 /* SileroCoreML */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -41,15 +46,23 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
0B4754099284EEDCD859A973 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SileroCoreMLBridge.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -57,6 +70,8 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -64,6 +79,16 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
8C5000042DD0000000000001 /* SileroCoreML in Frameworks */,
|
||||||
|
1E3B5BCCA481234F14E64D44 /* Pods_Runner.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
AF7C21841E770B5B95763515 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
3EF79A791760D95CE0F41CFF /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -78,6 +103,28 @@
|
|||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
4351F25046559EFA4C03047A /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0B4754099284EEDCD859A973 /* Pods_Runner.framework */,
|
||||||
|
63497078A621E2A73B102C46 /* Pods_RunnerTests.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8142F9547ECC31BB0C2830D3 /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7E043103010958FC2C6CA47F /* Pods-Runner.debug.xcconfig */,
|
||||||
|
C10A61C706CAF223682AC397 /* Pods-Runner.release.xcconfig */,
|
||||||
|
89E01DD0E6B92DA93A02E9D6 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
|
E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
|
076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -96,6 +143,8 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
8142F9547ECC31BB0C2830D3 /* Pods */,
|
||||||
|
4351F25046559EFA4C03047A /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -119,7 +168,9 @@
|
|||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
|
8C5000002DD0000000000001 /* SileroCoreMLBridge.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
1937FD83C5CC909094CDC137 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -131,8 +182,10 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
4CE2B0164E685A90D0C8A958 /* [CP] Check Pods Manifest.lock */,
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
AF7C21841E770B5B95763515 /* Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -148,18 +201,23 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
6FBA233EFE37134BD02075C2 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
8C5000032DD0000000000001 /* SileroCoreML */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -193,6 +251,9 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */,
|
||||||
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -219,6 +280,7 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
FD3C80659716BF7A0C95C7AF /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -241,6 +303,67 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
};
|
};
|
||||||
|
4CE2B0164E685A90D0C8A958 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
6FBA233EFE37134BD02075C2 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
7FE733EE83086540AF5D21CB /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -274,6 +397,7 @@
|
|||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
|
8C5000012DD0000000000001 /* SileroCoreMLBridge.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -350,7 +474,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -365,15 +489,19 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -382,13 +510,14 @@
|
|||||||
};
|
};
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 73171B86DD76CC3E5A58E160 /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -399,13 +528,14 @@
|
|||||||
};
|
};
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = E469085D9AE850FF6BD35704 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -414,13 +544,14 @@
|
|||||||
};
|
};
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 076D9E9796600FBC91FD7714 /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -476,7 +607,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -527,7 +658,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -544,15 +675,19 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -566,15 +701,19 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 349G7M4TQQ;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterRustBridgeHello;
|
PRODUCT_BUNDLE_IDENTIFIER = app.chanora.chanoraFlutter;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -615,6 +754,21 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../../silero-coreml;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
8C5000032DD0000000000001 /* SileroCoreML */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 8C5000022DD0000000000001 /* XCLocalSwiftPackageReference "silero-coreml" */;
|
||||||
|
productName = SileroCoreML;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
@@ -4,4 +4,7 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import UIKit
|
||||||
|
import Flutter
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||||
|
private var iosPlatformChannel: FlutterMethodChannel?
|
||||||
|
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
// Configure the iOS AVAudioSession **category + mode** at
|
||||||
|
// app-launch time, but DEFER setActive(true) until the scene
|
||||||
|
// is foregrounded. Calling setActive in didFinishLaunching is
|
||||||
|
// racy on iOS 17+ devices: if the user launches the app from a
|
||||||
|
// cold state, the UIApplication isn't yet `.active` and
|
||||||
|
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
||||||
|
// (561017449) — the iOS audio policy server refuses to grant
|
||||||
|
// the audio session because the app is not yet considered the
|
||||||
|
// foreground priority owner. Symptom in production builds:
|
||||||
|
// 'AVAudioSession setup failed: Error 561017449 "Session
|
||||||
|
// activation failed"' in NSLog, after which the audio engine
|
||||||
|
// is unusable until the user backgrounds + foregrounds the
|
||||||
|
// app.
|
||||||
|
//
|
||||||
|
// The category itself can be set whenever; only the active
|
||||||
|
// state needs to be deferred. We listen for
|
||||||
|
// didBecomeActiveNotification and activate then. Most
|
||||||
|
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
||||||
|
// this same shape.
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .voiceChat,
|
||||||
|
// Mode rationale (May 2026, .voiceChat reinstated):
|
||||||
|
//
|
||||||
|
// We previously used .default mode after discovering that
|
||||||
|
// .voiceChat routed output through iOS's in-call audio
|
||||||
|
// channel, which made speaker output barely audible. That
|
||||||
|
// bug was caused by cpal's RemoteIO unit binding to a stale
|
||||||
|
// physical transducer — after migrating to coreaudio-rs +
|
||||||
|
// kAudioUnitSubType_VoiceProcessingIO (see
|
||||||
|
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
||||||
|
// binding is correct under either mode because VPIO re-binds
|
||||||
|
// on overrideOutputAudioPort.
|
||||||
|
//
|
||||||
|
// .voiceChat advantages over .default:
|
||||||
|
// * Tells iOS this is a VoIP session — other apps' audio
|
||||||
|
// is properly ducked/paused instead of competing.
|
||||||
|
// * Enables correct Bluetooth HFP negotiation without
|
||||||
|
// manual workarounds.
|
||||||
|
// * iOS treats the audio session as a "call" for priority
|
||||||
|
// purposes (won't be interrupted by notification sounds).
|
||||||
|
// * System-level CallKit integration (lock-screen controls).
|
||||||
|
//
|
||||||
|
// .defaultToSpeaker ensures output goes to the main speaker
|
||||||
|
// (not the earpiece) by default when no headphones are
|
||||||
|
// connected, compensating for the in-call channel's tendency
|
||||||
|
// to route to the earpiece.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
||||||
|
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
// .defaultToSpeaker : route output to the main speaker
|
||||||
|
// (not the earpiece) by default
|
||||||
|
// when no headphones are connected.
|
||||||
|
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
||||||
|
// Profile headsets as both input
|
||||||
|
// and output.
|
||||||
|
// .allowBluetoothA2DP : permit higher-quality A2DP
|
||||||
|
// output-only Bluetooth devices.
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
||||||
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
|
try session.setPreferredSampleRate(48000.0)
|
||||||
|
logAudioSessionState(context: "setCategory")
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate the session once the app is actually foreground. The
|
||||||
|
// notification fires immediately after the cold-launch settles,
|
||||||
|
// and again on every resume-from-background — both safe
|
||||||
|
// moments to call setActive(true). Repeated activation while
|
||||||
|
// already-active is a no-op per the docs.
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(activateAudioSession),
|
||||||
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleRouteChange(_:)),
|
||||||
|
name: AVAudioSession.routeChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleInterruption(_:)),
|
||||||
|
name: AVAudioSession.interruptionNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleMediaServicesReset(_:)),
|
||||||
|
name: AVAudioSession.mediaServicesWereResetNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
||||||
|
/// every resume-from-background). Activates the AVAudioSession.
|
||||||
|
/// Repeated activation is a no-op when the session is already
|
||||||
|
/// active so this is safe to call on every foreground.
|
||||||
|
@objc private func activateAudioSession() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||||
|
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
||||||
|
// Read back the ACTUAL session state. preferredSampleRate /
|
||||||
|
// preferredIOBufferDuration are hints; iOS may pick something
|
||||||
|
// else depending on hardware + currently-engaged effects.
|
||||||
|
// Without these we can't tell whether VPIO is running at
|
||||||
|
// 48 kHz mono (what our render callback assumes) or at e.g.
|
||||||
|
// 44.1 kHz (which would explain the user's broken playback
|
||||||
|
// \u2014 our render callback would be writing samples at the
|
||||||
|
// wrong rate, causing pitch + timing artifacts).
|
||||||
|
logAudioSessionState(context: "setActive")
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
|
NSLog(
|
||||||
|
"chanora_flutter: AVAudioSession actual: " +
|
||||||
|
"sampleRate=\(s.sampleRate) " +
|
||||||
|
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||||
|
"inputs=[\(ins)] " +
|
||||||
|
"outputVolume=\(s.outputVolume)"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads back the actual AVAudioSession state and logs it for
|
||||||
|
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||||
|
/// to verify that the session accepted the requested configuration.
|
||||||
|
private func logAudioSessionState(context: String) {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
NSLog("chanora_flutter: [\(context)] category=\(s.category.rawValue) mode=\(s.mode.rawValue) options=\(s.categoryOptions.rawValue) route outputs=\(s.currentRoute.outputs.map { "\($0.portType.rawValue)" })")
|
||||||
|
if s.sampleRate != 48000.0 {
|
||||||
|
NSLog("chanora_flutter: WARNING: actual sample rate \(s.sampleRate) != requested 48000")
|
||||||
|
}
|
||||||
|
if s.ioBufferDuration > 0.025 {
|
||||||
|
NSLog("chanora_flutter: WARNING: IO buffer duration \(s.ioBufferDuration) > 25ms, may cause latency")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRouteChange(_ notification: Notification) {
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let routeDescription = AVAudioSession.sharedInstance().currentRoute
|
||||||
|
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
|
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
|
||||||
|
|
||||||
|
// P1: Send the detailed route class to Rust on every route change,
|
||||||
|
// not just device plug/unplug. This covers:
|
||||||
|
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
|
||||||
|
// - .override (speaker/earpiece toggle)
|
||||||
|
// - .categoryChange (session category changed)
|
||||||
|
// - .wakeFromSleep (device woke from sleep)
|
||||||
|
// - .routeConfigurationChange (BT HFP connect/disconnect)
|
||||||
|
// The Rust side uses the route class to recompute the processing
|
||||||
|
// policy (route_policy.rs) and reset AEC delay state if needed.
|
||||||
|
let routeClass = classifyAudioRoute(routeDescription)
|
||||||
|
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleInterruption(_ notification: Notification) {
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
NSLog("chanora_flutter: audio interruption began")
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionBegan", arguments: nil)
|
||||||
|
case .ended:
|
||||||
|
let shouldResume = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt)
|
||||||
|
.map { $0 & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 }
|
||||||
|
?? false
|
||||||
|
NSLog("chanora_flutter: audio interruption ended shouldResume=\(shouldResume)")
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionEnded", arguments: shouldResume)
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||||
|
NSLog("chanora_flutter: media services reset")
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .voiceChat,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
|
try session.setPreferredSampleRate(48000.0)
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
logAudioSessionState(context: "mediaServicesWereReset")
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||||
|
}
|
||||||
|
// P1: After rebuilding the session, send the current route class to
|
||||||
|
// Rust so it can recompute the processing policy and reset the
|
||||||
|
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||||
|
// which calls ios_restart_voice_unit.
|
||||||
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||||
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillResignActive(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
iosAudioLifecycleChannel = FlutterMethodChannel(
|
||||||
|
name: "chanora/ios_audio_lifecycle",
|
||||||
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
|
)
|
||||||
|
iosPlatformChannel = FlutterMethodChannel(
|
||||||
|
name: "chanora/ios_platform",
|
||||||
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
|
)
|
||||||
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||||
|
switch call.method {
|
||||||
|
case "getMicrophonePermissionState":
|
||||||
|
result(self.microphonePermissionStateString())
|
||||||
|
case "requestMicrophonePermission":
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(granted ? "Granted" : self.microphonePermissionStateString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "openAppSettings":
|
||||||
|
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
||||||
|
result(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIApplication.shared.open(url, options: [:]) { opened in
|
||||||
|
result(opened)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
|
||||||
|
for output in route.outputs {
|
||||||
|
switch output.portType {
|
||||||
|
case .builtInReceiver:
|
||||||
|
return "Earpiece"
|
||||||
|
case .builtInSpeaker:
|
||||||
|
return "Speaker"
|
||||||
|
case .headphones, .usbAudio:
|
||||||
|
return "WiredHeadset"
|
||||||
|
case .bluetoothHFP:
|
||||||
|
return "BluetoothHfp"
|
||||||
|
case .bluetoothA2DP:
|
||||||
|
return "BluetoothA2dp"
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func microphonePermissionStateString() -> String {
|
||||||
|
switch AVAudioSession.sharedInstance().recordPermission {
|
||||||
|
case .granted:
|
||||||
|
return "Granted"
|
||||||
|
case .denied:
|
||||||
|
return "Denied"
|
||||||
|
case .undetermined:
|
||||||
|
return "NotDetermined"
|
||||||
|
@unknown default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
@@ -4,10 +4,13 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
|
||||||
|
supported iPhones. Flutter's iOS embedder reads this key; no
|
||||||
|
additional Flutter package is required for dynamic refresh. -->
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Flutter Rust Bridge Hello</string>
|
<string>Chanora</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +18,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>flutter_rust_bridge_hello</string>
|
<string>Chanora</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -26,6 +29,8 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Chanora needs microphone access so you can talk on your TeamSpeak-compatible voice server.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
@@ -49,6 +54,12 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -66,5 +77,9 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Privacy Manifest for Chanora.
|
||||||
|
Apple started enforcing this file at App Store submission in
|
||||||
|
May 2024 for iOS / iPadOS / visionOS / watchOS apps. Without
|
||||||
|
it, archive upload to App Store Connect is rejected.
|
||||||
|
|
||||||
|
Reference: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Whether the app collects data that has been linked to the
|
||||||
|
user's identity. Chanora never gathers third-party analytics,
|
||||||
|
user accounts, ad identifiers, or device identifiers. The
|
||||||
|
only "data" leaving the device is the voice + chat content
|
||||||
|
the user explicitly sends to a TeamSpeak server they have
|
||||||
|
chosen. Per Apple's definition that is "not collected by
|
||||||
|
the app" because the destination is user-selected, not us.
|
||||||
|
-->
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<!-- Microphone audio that is transmitted to the user's
|
||||||
|
chosen voice server while the user is connected and
|
||||||
|
unmuted. Apple's data-type taxonomy classifies this
|
||||||
|
as "Audio Data". The audio is not linked to the user
|
||||||
|
(no Apple ID, no IDFA tied to the stream) and not
|
||||||
|
used for tracking. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<!-- Communications: the user is talking to other
|
||||||
|
people on their chosen server. -->
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- The app does not track users across other apps + websites
|
||||||
|
owned by other companies. -->
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
|
||||||
|
<!-- "Required reason" APIs Chanora uses. Apple maintains a list
|
||||||
|
of system APIs that need a declared reason because they
|
||||||
|
historically were abused for fingerprinting. Chanora uses
|
||||||
|
the file-timestamp APIs (via tokio file I/O for storing the
|
||||||
|
identity key + bookmark db + audio_meta + chanora.log) and
|
||||||
|
the user-defaults API (via Flutter's path_provider plugin
|
||||||
|
which queries NSUserDefaults to resolve Application Support
|
||||||
|
paths). Documented reasons below match Apple's published
|
||||||
|
allow-list. -->
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<!-- File-timestamp APIs used by tokio + rusqlite when
|
||||||
|
reading / writing identity.tskey, chanora.db, and
|
||||||
|
chanora.log. Reason `C617.1`: app-internal,
|
||||||
|
timestamps of files inside the app's container. -->
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- UserDefaults read indirectly via path_provider's
|
||||||
|
query for the app's documents directory. Reason
|
||||||
|
`CA92.1`: access user defaults to read information
|
||||||
|
only accessible to the app itself. -->
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- System boot time read by tracing-subscriber for
|
||||||
|
timestamping log records. Reason `35F9.1`: measure
|
||||||
|
elapsed time between events that occur within the
|
||||||
|
app. -->
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- Disk space queried by rusqlite when checking sqlite
|
||||||
|
page capacity. Reason `E174.1`: display disk space
|
||||||
|
to the user. Actually we don't display it; we just
|
||||||
|
read it. The next-closest reason is `85F4.1`:
|
||||||
|
ensure disk space available before writes. -->
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>85F4.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import CoreML
|
||||||
|
import Foundation
|
||||||
|
import SileroCoreML
|
||||||
|
|
||||||
|
private final class ChanoraSileroVadBox {
|
||||||
|
let vad: SileroVADRunner
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
let configuration = MLModelConfiguration()
|
||||||
|
vad = try SileroVADRunner(configuration: configuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let chanoraSileroErrorLock = NSLock()
|
||||||
|
private var chanoraSileroLastError = ""
|
||||||
|
|
||||||
|
private func setChanoraSileroLastError(_ message: String) {
|
||||||
|
chanoraSileroErrorLock.lock()
|
||||||
|
chanoraSileroLastError = message
|
||||||
|
chanoraSileroErrorLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("chanora_silero_vad_create")
|
||||||
|
public func chanoraSileroVadCreate() -> UnsafeMutableRawPointer? {
|
||||||
|
do {
|
||||||
|
let box = try ChanoraSileroVadBox()
|
||||||
|
return Unmanaged.passRetained(box).toOpaque()
|
||||||
|
} catch {
|
||||||
|
setChanoraSileroLastError(String(describing: error))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("chanora_silero_vad_destroy")
|
||||||
|
public func chanoraSileroVadDestroy(_ handle: UnsafeMutableRawPointer?) {
|
||||||
|
guard let handle else { return }
|
||||||
|
Unmanaged<ChanoraSileroVadBox>.fromOpaque(handle).release()
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("chanora_silero_vad_reset")
|
||||||
|
public func chanoraSileroVadReset(_ handle: UnsafeMutableRawPointer?) -> Int32 {
|
||||||
|
guard let handle else {
|
||||||
|
setChanoraSileroLastError("SileroVAD handle is null")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
let box = Unmanaged<ChanoraSileroVadBox>.fromOpaque(handle).takeUnretainedValue()
|
||||||
|
box.vad.reset()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("chanora_silero_vad_process")
|
||||||
|
public func chanoraSileroVadProcess(
|
||||||
|
_ handle: UnsafeMutableRawPointer?,
|
||||||
|
_ samples: UnsafePointer<Float>?,
|
||||||
|
_ sampleCount: Int,
|
||||||
|
_ probabilityOut: UnsafeMutablePointer<Float>?
|
||||||
|
) -> Int32 {
|
||||||
|
guard let handle else {
|
||||||
|
setChanoraSileroLastError("SileroVAD handle is null")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
guard let samples else {
|
||||||
|
setChanoraSileroLastError("SileroVAD samples pointer is null")
|
||||||
|
return -2
|
||||||
|
}
|
||||||
|
guard let probabilityOut else {
|
||||||
|
setChanoraSileroLastError("SileroVAD probability output pointer is null")
|
||||||
|
return -3
|
||||||
|
}
|
||||||
|
guard sampleCount == SileroVADRunner.chunkSize else {
|
||||||
|
setChanoraSileroLastError("SileroVAD expected \(SileroVADRunner.chunkSize) samples, got \(sampleCount)")
|
||||||
|
return -4
|
||||||
|
}
|
||||||
|
|
||||||
|
let box = Unmanaged<ChanoraSileroVadBox>.fromOpaque(handle).takeUnretainedValue()
|
||||||
|
do {
|
||||||
|
let chunk = Array(UnsafeBufferPointer(start: samples, count: sampleCount))
|
||||||
|
probabilityOut.pointee = try box.vad.process(chunk)
|
||||||
|
return 0
|
||||||
|
} catch {
|
||||||
|
setChanoraSileroLastError(String(describing: error))
|
||||||
|
return -5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("chanora_silero_vad_last_error")
|
||||||
|
public func chanoraSileroVadLastError() -> UnsafeMutablePointer<CChar>? {
|
||||||
|
chanoraSileroErrorLock.lock()
|
||||||
|
let message = chanoraSileroLastError
|
||||||
|
chanoraSileroErrorLock.unlock()
|
||||||
|
return strdup(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@_cdecl("chanora_silero_vad_free_string")
|
||||||
|
public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
|
||||||
|
guard let string else { return }
|
||||||
|
free(string)
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#
|
||||||
|
# chanora_bridge — Rust bridge library as a CocoaPods-vended dynamic framework.
|
||||||
|
#
|
||||||
|
# What this file does:
|
||||||
|
#
|
||||||
|
# 1. `prepare_command` runs once per `pod install` and once per archive
|
||||||
|
# / device build. It:
|
||||||
|
# a) Invokes `cargo build --release --target <iOS arch>` for both
|
||||||
|
# aarch64-apple-ios (device) and aarch64-apple-ios-sim
|
||||||
|
# (simulator on Apple Silicon) so the resulting framework
|
||||||
|
# works for both targets.
|
||||||
|
# b) Wraps the built dylib in a proper `chanora_bridge.framework`
|
||||||
|
# bundle (Versions/A layout with the right Info.plist).
|
||||||
|
# c) Rewrites LC_ID_DYLIB to
|
||||||
|
# @rpath/chanora_bridge.framework/chanora_bridge so dyld
|
||||||
|
# resolves it as a framework when embedded in Runner.app.
|
||||||
|
#
|
||||||
|
# 2. `vendored_frameworks` exposes the result to the Pods workspace.
|
||||||
|
# CocoaPods then integrates the framework into Runner.xcodeproj
|
||||||
|
# with the appropriate embed-and-sign build phase.
|
||||||
|
#
|
||||||
|
# Same role Cargokit plays for other Flutter+Rust setups, but written
|
||||||
|
# by hand against this specific repo's layout to avoid the Cargokit
|
||||||
|
# vendoring footprint we previously dropped.
|
||||||
|
#
|
||||||
|
# Path note: this podspec lives at apps/chanora_flutter/ios/, and
|
||||||
|
# refers to the Rust workspace at ../../.. (repo root). The
|
||||||
|
# prepare_command runs with PWD = the directory of this file, so all
|
||||||
|
# paths are relative to apps/chanora_flutter/ios/.
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'chanora_bridge'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Chanora Rust bridge (libchanora_bridge) as an iOS framework.'
|
||||||
|
s.description = <<-DESC
|
||||||
|
Vendors the Rust-built libchanora_bridge as chanora_bridge.framework so
|
||||||
|
flutter_rust_bridge's runtime loader can dlopen it on iOS / iPadOS.
|
||||||
|
DESC
|
||||||
|
s.homepage = 'https://github.com/EdisonJwa/chanora'
|
||||||
|
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
|
||||||
|
s.author = { 'EdisonJwa' => 'me@edison.network' }
|
||||||
|
s.source = { :path => '.' }
|
||||||
|
s.platform = :ios, '16.0'
|
||||||
|
|
||||||
|
# Build the Rust bridge on `pod install`. The script runs under
|
||||||
|
# bash; we use `set -e` so any failure (cargo missing, target not
|
||||||
|
# installed, link error) aborts the install loudly. The
|
||||||
|
# CMAKE_POLICY_VERSION_MINIMUM + IPHONEOS_DEPLOYMENT_TARGET
|
||||||
|
# env vars satisfy audiopus_sys's cmake invocation on modern
|
||||||
|
# CMake 4.x and Apple iOS SDK; both are required.
|
||||||
|
s.prepare_command = <<-SCRIPT
|
||||||
|
set -e
|
||||||
|
REPO_ROOT="$(cd ../../.. && pwd)"
|
||||||
|
USER_NAME="$(id -un)"
|
||||||
|
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||||
|
if [ -z "$USER_HOME" ]; then
|
||||||
|
USER_HOME="$(cd ~ && pwd)"
|
||||||
|
fi
|
||||||
|
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
|
||||||
|
find_cargo() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/cargo" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/cargo" \
|
||||||
|
"/usr/local/opt/rustup/bin/cargo"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v cargo
|
||||||
|
}
|
||||||
|
find_rustc() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/rustc" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/rustc" \
|
||||||
|
"/usr/local/opt/rustup/bin/rustc"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v rustc
|
||||||
|
}
|
||||||
|
CARGO_BIN="$(find_cargo)"
|
||||||
|
RUSTC_BIN="$(find_rustc)"
|
||||||
|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
HOME="$USER_HOME" \\
|
||||||
|
CARGO_HOME="$USER_HOME/.cargo" \\
|
||||||
|
RUSTUP_HOME="$USER_HOME/.rustup" \\
|
||||||
|
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
|
||||||
|
RUSTC="$RUSTC_BIN" \\
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
|
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
|
||||||
|
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
|
||||||
|
|
||||||
|
if [ ! -f "$BRIDGE" ]; then
|
||||||
|
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
||||||
|
|
||||||
|
# Build the chanora_bridge.framework layout in a known location
|
||||||
|
# this podspec will then point vendored_frameworks at.
|
||||||
|
FW=Frameworks/chanora_bridge.framework
|
||||||
|
rm -rf "$FW"
|
||||||
|
mkdir -p "$FW"
|
||||||
|
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||||
|
|
||||||
|
cat > "$FW/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key><string>chanora_bridge</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
|
||||||
|
<key>CFBundleName</key><string>chanora_bridge</string>
|
||||||
|
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
||||||
|
<key>MinimumOSVersion</key><string>16.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||||
|
"$FW/chanora_bridge"
|
||||||
|
echo "[chanora_bridge.podspec] framework ready at $FW"
|
||||||
|
SCRIPT
|
||||||
|
|
||||||
|
# Pod CocoaPods picks this up; the framework gets embedded into
|
||||||
|
# Runner.app/Frameworks/ with Embed & Sign automatically.
|
||||||
|
s.vendored_frameworks = 'Frameworks/chanora_bridge.framework'
|
||||||
|
|
||||||
|
# Re-run the cargo build + framework wrap on every Xcode build,
|
||||||
|
# not only on `pod install`. The `prepare_command` above runs
|
||||||
|
# once per `pod install` which is too sticky — Rust source
|
||||||
|
# changes were getting silently ignored because Xcode happily
|
||||||
|
# re-bundled the stale framework. This script_phase shells out to
|
||||||
|
# cargo on every Xcode "Build" so the framework is always in sync
|
||||||
|
# with the current Rust workspace.
|
||||||
|
#
|
||||||
|
# `:execution_position => :before_compile` runs the script before
|
||||||
|
# Xcode's CompileSources phase, so by the time the linker / embed
|
||||||
|
# step sees `Frameworks/chanora_bridge.framework`, it is fresh.
|
||||||
|
s.script_phase = {
|
||||||
|
:name => 'Rebuild chanora_bridge.framework from Rust',
|
||||||
|
:script => <<-SCRIPT,
|
||||||
|
set -e
|
||||||
|
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
|
||||||
|
USER_NAME="$(id -un)"
|
||||||
|
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||||
|
if [ -z "$USER_HOME" ]; then
|
||||||
|
USER_HOME="$(cd ~ && pwd)"
|
||||||
|
fi
|
||||||
|
find_cargo() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/cargo" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/cargo" \
|
||||||
|
"/usr/local/opt/rustup/bin/cargo"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v cargo
|
||||||
|
}
|
||||||
|
find_rustc() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/rustc" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/rustc" \
|
||||||
|
"/usr/local/opt/rustup/bin/rustc"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v rustc
|
||||||
|
}
|
||||||
|
CARGO_BIN="$(find_cargo)"
|
||||||
|
RUSTC_BIN="$(find_rustc)"
|
||||||
|
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
|
||||||
|
RUST_TARGET="aarch64-apple-ios-sim"
|
||||||
|
SUPPORTED_PLATFORM="iPhoneSimulator"
|
||||||
|
else
|
||||||
|
RUST_TARGET="aarch64-apple-ios"
|
||||||
|
SUPPORTED_PLATFORM="iPhoneOS"
|
||||||
|
fi
|
||||||
|
BRIDGE="$REPO_ROOT/target/$RUST_TARGET/release/libchanora_bridge.dylib"
|
||||||
|
echo "[chanora_bridge script_phase] cargo build $RUST_TARGET"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
HOME="$USER_HOME" \\
|
||||||
|
CARGO_HOME="$USER_HOME/.cargo" \\
|
||||||
|
RUSTUP_HOME="$USER_HOME/.rustup" \\
|
||||||
|
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
|
||||||
|
RUSTC="$RUSTC_BIN" \\
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
|
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
|
||||||
|
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
|
||||||
|
|
||||||
|
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
||||||
|
FW=Frameworks/chanora_bridge.framework
|
||||||
|
|
||||||
|
# Skip the wrap step if the framework's binary is already
|
||||||
|
# up-to-date with the cargo output (fast no-op on incremental
|
||||||
|
# builds where Rust didn't change).
|
||||||
|
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
|
||||||
|
echo "[chanora_bridge script_phase] framework already up-to-date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$FW"
|
||||||
|
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||||
|
cat > "$FW/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key><string>chanora_bridge</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
|
||||||
|
<key>CFBundleName</key><string>chanora_bridge</string>
|
||||||
|
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleSupportedPlatforms</key><array><string>$SUPPORTED_PLATFORM</string></array>
|
||||||
|
<key>MinimumOSVersion</key><string>16.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||||
|
"$FW/chanora_bridge"
|
||||||
|
echo "[chanora_bridge script_phase] framework refreshed"
|
||||||
|
SCRIPT
|
||||||
|
:execution_position => :before_compile,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The pod has no Objective-C sources; it's purely a framework
|
||||||
|
# carrier. Suppress CocoaPods's source-file warning.
|
||||||
|
s.source_files = 'Frameworks/chanora_bridge.framework/Headers/*.h'
|
||||||
|
|
||||||
|
# No public Swift / ObjC API to expose — the Flutter app talks to
|
||||||
|
# the framework via FRB's FFI symbol lookup at runtime, not via
|
||||||
|
# imports.
|
||||||
|
end
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Chanora design tokens for connection, voice, and latency states.
|
||||||
|
// Maps abstract server/voice/network state to Material 3 colour, icon,
|
||||||
|
// and label affordances.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Tokenised visual mapping for a connection state.
|
||||||
|
class ConnectionTokens {
|
||||||
|
final Color color;
|
||||||
|
final Color? background;
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const ConnectionTokens({
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
this.background,
|
||||||
|
});
|
||||||
|
|
||||||
|
static ConnectionTokens disconnected(ColorScheme cs) => ConnectionTokens(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
background: cs.surfaceContainerHighest,
|
||||||
|
icon: Icons.cloud_off,
|
||||||
|
label: 'Disconnected',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ConnectionTokens connecting(ColorScheme cs) => ConnectionTokens(
|
||||||
|
color: cs.primary,
|
||||||
|
background: cs.primaryContainer,
|
||||||
|
icon: Icons.sync,
|
||||||
|
label: 'Connecting',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ConnectionTokens synchronizing(ColorScheme cs) => ConnectionTokens(
|
||||||
|
color: cs.secondary,
|
||||||
|
background: cs.secondaryContainer,
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
label: 'Synchronizing',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ConnectionTokens connected(ColorScheme cs) => ConnectionTokens(
|
||||||
|
color: cs.tertiary,
|
||||||
|
background: cs.tertiaryContainer,
|
||||||
|
icon: Icons.cloud_done,
|
||||||
|
label: 'Connected',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ConnectionTokens reconnecting(ColorScheme cs) => ConnectionTokens(
|
||||||
|
color: cs.error,
|
||||||
|
background: cs.errorContainer,
|
||||||
|
icon: Icons.restart_alt,
|
||||||
|
label: 'Reconnecting',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ConnectionTokens lost(ColorScheme cs) => ConnectionTokens(
|
||||||
|
color: cs.error,
|
||||||
|
background: cs.errorContainer,
|
||||||
|
icon: Icons.warning_amber,
|
||||||
|
label: 'Connection Lost',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenised visual mapping for a voice transmit/mute state.
|
||||||
|
class VoiceTokens {
|
||||||
|
final Color color;
|
||||||
|
final Color? background;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const VoiceTokens({
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
this.background,
|
||||||
|
});
|
||||||
|
|
||||||
|
static VoiceTokens idle(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
icon: Icons.mic_none,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VoiceTokens pttHeld(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.primary,
|
||||||
|
background: cs.primaryContainer,
|
||||||
|
icon: Icons.mic,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VoiceTokens voiceActivity(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.tertiary,
|
||||||
|
background: cs.tertiaryContainer,
|
||||||
|
icon: Icons.mic,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VoiceTokens continuousTx(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.primary,
|
||||||
|
background: cs.primaryContainer,
|
||||||
|
icon: Icons.settings_voice,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VoiceTokens muted(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.error,
|
||||||
|
background: cs.errorContainer,
|
||||||
|
icon: Icons.mic_off,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VoiceTokens outputMuted(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.error,
|
||||||
|
icon: Icons.headset_off,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VoiceTokens deafened(ColorScheme cs) => VoiceTokens(
|
||||||
|
color: cs.error,
|
||||||
|
background: cs.errorContainer,
|
||||||
|
icon: Icons.hearing_disabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenised visual mapping for network latency/quality.
|
||||||
|
class LatencyTokens {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const LatencyTokens({required this.color, required this.label});
|
||||||
|
|
||||||
|
static LatencyTokens good(ColorScheme cs) => LatencyTokens(
|
||||||
|
color: cs.tertiary,
|
||||||
|
label: 'Good',
|
||||||
|
);
|
||||||
|
|
||||||
|
static LatencyTokens warning(ColorScheme cs) => LatencyTokens(
|
||||||
|
color: cs.error,
|
||||||
|
label: 'Warning',
|
||||||
|
);
|
||||||
|
|
||||||
|
static LatencyTokens unknown(ColorScheme cs) => LatencyTokens(
|
||||||
|
color: cs.outlineVariant,
|
||||||
|
label: '—',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens for channel membership status.
|
||||||
|
class ChannelTokens {
|
||||||
|
final IconData? icon;
|
||||||
|
final String? label;
|
||||||
|
|
||||||
|
const ChannelTokens({this.icon, this.label});
|
||||||
|
|
||||||
|
static ChannelTokens inChannel(ColorScheme cs) => ChannelTokens(
|
||||||
|
icon: Icons.headset_mic,
|
||||||
|
label: 'Connected',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ChannelTokens joining(ColorScheme cs) => ChannelTokens(
|
||||||
|
icon: Icons.login,
|
||||||
|
label: 'Joining...',
|
||||||
|
);
|
||||||
|
|
||||||
|
static ChannelTokens notInChannel(ColorScheme cs) => ChannelTokens(
|
||||||
|
icon: null,
|
||||||
|
label: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens for PTT capability display.
|
||||||
|
class PttTokens {
|
||||||
|
final String label;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const PttTokens({required this.label, this.color});
|
||||||
|
|
||||||
|
static PttTokens focused(ColorScheme cs) => PttTokens(
|
||||||
|
label: 'Keyboard PTT',
|
||||||
|
color: cs.tertiary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static PttTokens platformHook(ColorScheme cs) => PttTokens(
|
||||||
|
label: 'Global PTT',
|
||||||
|
color: cs.primary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static PttTokens none(ColorScheme cs) => PttTokens(
|
||||||
|
label: 'No PTT input',
|
||||||
|
color: cs.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// SRS-115: Unsupported/degraded platform behaviours documentation.
|
||||||
|
// Describes what is available, degraded, or unavailable per platform.
|
||||||
|
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
|
/// Describes capability tier for a feature on a platform.
|
||||||
|
enum CapabilityTier { supported, degraded, unavailable }
|
||||||
|
|
||||||
|
/// A single platform capability description.
|
||||||
|
class PlatformCapability {
|
||||||
|
final String feature;
|
||||||
|
final String description;
|
||||||
|
final CapabilityTier tier;
|
||||||
|
|
||||||
|
const PlatformCapability({
|
||||||
|
required this.feature,
|
||||||
|
required this.description,
|
||||||
|
required this.tier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current platform's capability matrix.
|
||||||
|
List<PlatformCapability> currentPlatformCapabilities() {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return [
|
||||||
|
PlatformCapability(
|
||||||
|
feature: 'Audio',
|
||||||
|
description: 'Web audio is not supported in the beta.',
|
||||||
|
tier: CapabilityTier.unavailable,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return _androidCapabilities();
|
||||||
|
}
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return _iosCapabilities();
|
||||||
|
}
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
return _macosCapabilities();
|
||||||
|
}
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
return _windowsCapabilities();
|
||||||
|
}
|
||||||
|
if (Platform.isLinux) {
|
||||||
|
return _linuxCapabilities();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PlatformCapability> _androidCapabilities() => [
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice capture',
|
||||||
|
description: 'Oboe AAudio backend; Bluetooth SCO supported.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice processing',
|
||||||
|
description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Foreground service',
|
||||||
|
description: 'Persistent notification during active connection.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Global PTT hotkey',
|
||||||
|
description: 'Not available on Android.',
|
||||||
|
tier: CapabilityTier.unavailable,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Audio device selection',
|
||||||
|
description: 'System-managed; wired/BT/SCO automatic routing.',
|
||||||
|
tier: CapabilityTier.degraded,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Push-to-talk button',
|
||||||
|
description: 'Media button / volume-key binding supported.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<PlatformCapability> _iosCapabilities() => [
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice capture',
|
||||||
|
description: 'VoiceProcessingIO AudioUnit (default).',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice processing',
|
||||||
|
description: 'Platform AEC/NS/AGC via VPIO.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Audio route switching',
|
||||||
|
description:
|
||||||
|
'Handles speaker/earpiece/Bluetooth route changes and interruptions.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Global PTT hotkey',
|
||||||
|
description: 'Not available on iOS.',
|
||||||
|
tier: CapabilityTier.unavailable,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Background audio',
|
||||||
|
description: 'Supported via AVAudioSession background mode.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<PlatformCapability> _macosCapabilities() => [
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice capture',
|
||||||
|
description: 'cpal device enumeration; VoiceProcessingIO optional.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice processing',
|
||||||
|
description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Global PTT hotkey',
|
||||||
|
description: 'Supported via platform-global key-binding API.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Secure storage',
|
||||||
|
description: 'macOS Keychain.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Audio device selection',
|
||||||
|
description: 'System audio output route picker.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<PlatformCapability> _windowsCapabilities() => [
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice capture',
|
||||||
|
description: 'WASAPI via cpal.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice processing',
|
||||||
|
description: 'WebRTC AEC3/NS/AGC2 with Silero VAD.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Global PTT hotkey',
|
||||||
|
description: 'Supported via platform-global hotkey binding.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Secure storage',
|
||||||
|
description: 'Windows Credential Manager / DPAPI.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Installer',
|
||||||
|
description: 'MSIX packaging not yet available in beta.',
|
||||||
|
tier: CapabilityTier.unavailable,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<PlatformCapability> _linuxCapabilities() => [
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice capture',
|
||||||
|
description: 'PulseAudio/ALSA via cpal.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Voice processing',
|
||||||
|
description: 'WebRTC AEC3/NS/AGC2 with Silero VAD; no platform VPIO.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Global PTT hotkey',
|
||||||
|
description: 'Supported via X11/Wayland global key-binding. ',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Secure storage',
|
||||||
|
description: 'Secret Service / libsecret.',
|
||||||
|
tier: CapabilityTier.supported,
|
||||||
|
),
|
||||||
|
const PlatformCapability(
|
||||||
|
feature: 'Desktop environment',
|
||||||
|
description: 'DE-specific behaviour: screen locker may suspend audio.',
|
||||||
|
tier: CapabilityTier.degraded,
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -1,65 +1,302 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Template ARB for English. Other locales must reference these keys. Server-provided content is NOT translated (ADR-008 / DEC-015).",
|
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Template ARB for English. Server-provided content is NOT translated (ADR-008 / DEC-015).",
|
||||||
|
|
||||||
"appTitle": "Chanora",
|
"appTitle": "Chanora",
|
||||||
"@appTitle": {
|
|
||||||
"description": "Application title shown in launchers and the app bar. The product name `Chanora` is fixed by DEC-018 and must not be translated."
|
|
||||||
},
|
|
||||||
|
|
||||||
"homeNotProductionReadyBanner": "Alpha build — not production ready.",
|
"homeNotProductionReadyBanner": "Beta build — voice in/out wired; not production ready.",
|
||||||
"@homeNotProductionReadyBanner": {
|
|
||||||
"description": "Plain-language banner informing testers that this build is not for end-user use. Mirrors README.md's status line."
|
|
||||||
},
|
|
||||||
|
|
||||||
"fieldServerHost": "Server address",
|
"fieldServerHost": "Server address",
|
||||||
"@fieldServerHost": {
|
|
||||||
"description": "Label for the server-address input on the connect form."
|
|
||||||
},
|
|
||||||
"fieldNickname": "Nickname",
|
"fieldNickname": "Nickname",
|
||||||
"@fieldNickname": {
|
"fieldServerPassword": "Server password",
|
||||||
"description": "Label for the nickname input on the connect form."
|
"fieldServerPasswordHelp": "Leave blank if the server does not require one.",
|
||||||
},
|
"fieldPassword": "Password",
|
||||||
|
"fieldDisplayName": "Display name",
|
||||||
|
|
||||||
"connectAction": "Connect",
|
"connectAction": "Connect",
|
||||||
"@connectAction": {
|
"disconnectAction": "Leave server",
|
||||||
"description": "Label for the button that initiates a connection."
|
"disconnectConfirmTitle": "Leave server?",
|
||||||
},
|
"disconnectConfirmBody": "You will disconnect from the current server.",
|
||||||
"disconnectAction": "Disconnect",
|
"disconnectConfirmCancel": "Stay",
|
||||||
"@disconnectAction": {
|
"disconnectConfirmAction": "Leave",
|
||||||
"description": "Label for the button that ends the active connection."
|
|
||||||
},
|
|
||||||
"refreshAction": "Refresh",
|
"refreshAction": "Refresh",
|
||||||
"@refreshAction": {
|
"diagnosticsAction": "Diagnostics",
|
||||||
"description": "Label for the button that re-fetches the server snapshot."
|
"diagnosticsSaveAction": "Save export",
|
||||||
|
"diagnosticsLiveUpdating": "Live updating",
|
||||||
|
"shareAction": "Share",
|
||||||
|
"diagnosticsSaved": "Diagnostic export saved to {path}",
|
||||||
|
"@diagnosticsSaved": {
|
||||||
|
"placeholders": { "path": { "type": "String" } }
|
||||||
},
|
},
|
||||||
|
"aboutAction": "About",
|
||||||
|
"aboutVersion": "Version {version}",
|
||||||
|
"@aboutVersion": {
|
||||||
|
"placeholders": { "version": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"aboutAuthor": "Author: Edison Jwa",
|
||||||
|
"aboutNonAffiliation": "Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.",
|
||||||
|
"aboutLicenseHeading": "License",
|
||||||
|
"aboutLicenseBody": "Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.",
|
||||||
|
"aboutThirdPartyHeading": "Third-party software",
|
||||||
|
"aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.",
|
||||||
|
"copyAction": "Copy",
|
||||||
|
"closeAction": "Close",
|
||||||
|
"openAction": "Open",
|
||||||
|
"cancelAction": "Cancel",
|
||||||
|
"retryAction": "Retry",
|
||||||
|
"chatAction": "Chat",
|
||||||
|
"chatCloseAction": "Close chat",
|
||||||
|
"chatNewPrivateAction": "New private chat",
|
||||||
|
"chatSearchClientsHint": "Search clients...",
|
||||||
|
"chatDirectMessageAction": "Private message",
|
||||||
|
"chatPokeAction": "Poke",
|
||||||
|
"clientInfoAction": "Info",
|
||||||
|
"startAudioAction": "Start audio",
|
||||||
|
"pttHoldToTalk": "Hold to talk",
|
||||||
|
"pttTransmitting": "Transmitting…",
|
||||||
|
"pttHoldToTalkSemanticsHint": "Press and hold to transmit voice; release to stop.",
|
||||||
|
"pttCapabilityBadge": "PTT: {level} ({backend})",
|
||||||
|
"@pttCapabilityBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"level": { "type": "String" },
|
||||||
|
"backend": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pttCapabilityBoundKey": "Key: {key}",
|
||||||
|
"@pttCapabilityBoundKey": {
|
||||||
|
"placeholders": {
|
||||||
|
"key": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pttCapabilityExplainTitle": "Push-to-Talk capability",
|
||||||
|
"pttCapabilityExplainFocusedHeading": "Focused PTT",
|
||||||
|
"pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.",
|
||||||
|
"pttCapabilityExplainGoGlobalWindows": "On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.",
|
||||||
|
"pttCapabilityExplainGoGlobalMacos": "On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.",
|
||||||
|
"pttCapabilityExplainGoGlobalLinux": "On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop's shortcut dialog when it appears.",
|
||||||
|
"pttCapabilityExplainGoGlobalIos": "On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.",
|
||||||
|
"pttCapabilityExplainGoGlobalGeneric": "Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.",
|
||||||
|
"pttConfigureAction": "Configure",
|
||||||
|
"pttConfigureTitle": "Configure Push-to-Talk binding",
|
||||||
|
"pttConfigurePrompt": "Press the key or mouse side button you want to use for Push-to-Talk.",
|
||||||
|
"pttConfigureWaiting": "(waiting for input…)",
|
||||||
|
"pttConfigureCaptured": "Captured",
|
||||||
|
"pttConfigurePrivacyNote": "Chanora never logs the actual key value. Only the input class (keyboard / mouse-side-button) and a platform-neutral label leave this dialog.",
|
||||||
|
"pttConfigureSaveAction": "Save",
|
||||||
|
"pttConfigurePortalRedirect": "Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.",
|
||||||
|
"inputMuteAction": "Mute mic",
|
||||||
|
"inputUnmuteAction": "Unmute mic",
|
||||||
|
"outputMuteAction": "Mute speaker",
|
||||||
|
"outputUnmuteAction": "Unmute speaker",
|
||||||
|
"joinChannelAction": "Join channel",
|
||||||
|
"leaveChannelAction": "Leave channel",
|
||||||
|
"channelPasswordTitle": "Channel password",
|
||||||
|
"bookmarksHeading": "Bookmarks",
|
||||||
|
"bookmarksEmpty": "No bookmarks yet. Enter a server above and tap \"Save bookmark\".",
|
||||||
|
"bookmarkAddAction": "Save bookmark",
|
||||||
|
"bookmarkAddTitle": "Save bookmark",
|
||||||
|
"bookmarkDeleteAction": "Delete bookmark",
|
||||||
|
|
||||||
"statusIdle": "Not connected",
|
"statusIdle": "Not connected",
|
||||||
"@statusIdle": {},
|
|
||||||
"statusConnecting": "Connecting…",
|
"statusConnecting": "Connecting…",
|
||||||
"@statusConnecting": {},
|
|
||||||
"statusConnected": "Connected to {server}",
|
"statusConnected": "Connected to {server}",
|
||||||
"@statusConnected": {
|
"@statusConnected": {
|
||||||
"placeholders": {
|
"placeholders": { "server": { "type": "String" } }
|
||||||
"server": { "type": "String" }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"statusError": "Error: {message}",
|
"statusError": "Error: {message}",
|
||||||
"@statusError": {
|
"@statusError": {
|
||||||
|
"placeholders": { "message": { "type": "String" } }
|
||||||
|
},
|
||||||
|
|
||||||
|
"statusReconnecting": "Reconnecting… attempt {attempt}, in {delay}s",
|
||||||
|
"@statusReconnecting": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"message": { "type": "String" }
|
"attempt": { "type": "int" },
|
||||||
|
"delay": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statusConnectionLost": "Connection lost: {reason}",
|
||||||
|
"@statusConnectionLost": {
|
||||||
|
"placeholders": { "reason": { "type": "String" } }
|
||||||
|
},
|
||||||
|
|
||||||
|
"audioStatsLine": "TX {sent} frames • RX {received} frames • Mic {ptt}",
|
||||||
|
"@audioStatsLine": {
|
||||||
|
"placeholders": {
|
||||||
|
"sent": { "type": "int" },
|
||||||
|
"received": { "type": "int" },
|
||||||
|
"ptt": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"channelsHeading": "Channels",
|
"channelsHeading": "Channels",
|
||||||
"@channelsHeading": {},
|
|
||||||
"clientsHeading": "Online clients",
|
"clientsHeading": "Online clients",
|
||||||
"@clientsHeading": {},
|
"serverWelcomeHeading": "Server welcome message",
|
||||||
"countChannelsAndClients": "{channels} channels • {clients} online",
|
"countChannelsAndClients": "{channels} channels • {clients} online",
|
||||||
"@countChannelsAndClients": {
|
"@countChannelsAndClients": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"channels": { "type": "int" },
|
"channels": { "type": "int" },
|
||||||
"clients": { "type": "int" }
|
"clients": { "type": "int" }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"voiceModePtt": "PTT",
|
||||||
|
"voiceModeContinuous": "Continuous",
|
||||||
|
"voiceModeVoiceActivity": "Voice activity",
|
||||||
|
"voiceModeComingSoon": "Coming soon",
|
||||||
|
"voiceModeLabel": "Transmit mode",
|
||||||
|
"voiceReleaseTailLabel": "Release tail",
|
||||||
|
"voiceReleaseTailHint": "ms",
|
||||||
|
"voiceLeaveAction": "Leave voice",
|
||||||
|
"voiceHardMuteLabel": "Mute microphone",
|
||||||
|
"voiceOutputMuteLabel": "Mute speakers",
|
||||||
|
"voiceSettingsTitle": "Voice settings",
|
||||||
|
"voiceBindKeyAction": "Bind PTT key",
|
||||||
|
"voiceBoundKeyLabel": "Bound key",
|
||||||
|
"voicePttHoldHint": "Hold the button",
|
||||||
|
"voiceMicOn": "on",
|
||||||
|
"voiceMicOff": "off",
|
||||||
|
"voiceSheetTitle": "Voice",
|
||||||
|
"audioOutputLabel": "Audio output",
|
||||||
|
"audioRouteSpeaker": "Speaker",
|
||||||
|
"audioRouteReceiver": "iPhone receiver",
|
||||||
|
"audioRouteBluetooth": "Bluetooth",
|
||||||
|
"audioRouteWiredHeadset": "Wired headset",
|
||||||
|
"audioRouteCarAudio": "Car audio",
|
||||||
|
"audioRouteAirplay": "AirPlay",
|
||||||
|
"audioRouteUnknown": "Unknown",
|
||||||
|
"channelJoinAlreadyIn": "Already in this channel.",
|
||||||
|
"channelJoinFailedPermission": "Insufficient permission to join this channel.",
|
||||||
|
"channelJoinFailedPassword": "Wrong channel password.",
|
||||||
|
"channelJoinFailedFull": "Channel is full.",
|
||||||
|
"channelJoinFailedFamilyFull": "Channel family limit reached.",
|
||||||
|
"channelJoinFailedPrivate": "This channel is private.",
|
||||||
|
"channelJoinFailedTimeout": "Could not join channel: the server didn't respond in time.",
|
||||||
|
"channelJoinFailedFlooding": "Too many channel switches. Please wait a moment.",
|
||||||
|
"channelJoinFailedGeneric": "Could not join channel: {message}",
|
||||||
|
"@channelJoinFailedGeneric": {
|
||||||
|
"placeholders": {
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networkPermissionTitle": "Network Permission Required",
|
||||||
|
"networkPermissionBody": "Chanora needs permission to access the network. On macOS, go to System Settings → Privacy & Security → Local Network and enable Chanora, then try again.",
|
||||||
|
"networkPermissionOpenSettings": "Open System Settings",
|
||||||
|
"microphonePermissionTitle": "Microphone Permission Required",
|
||||||
|
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
|
||||||
|
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
|
||||||
|
"permissionGrantAction": "Grant",
|
||||||
|
"startupPermissionsTitle": "Permissions",
|
||||||
|
"startupPermissionsBody": "Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.",
|
||||||
|
"startupPermissionsNotNow": "Not now",
|
||||||
|
"startupPermissionsAllow": "Allow",
|
||||||
|
"audioRouteSystemDefault": "System default",
|
||||||
|
"audioRouteEarpiece": "Earpiece",
|
||||||
|
"audioRouteUsbHeadset": "USB headset",
|
||||||
|
"audioRouteOtherDevice": "Other device",
|
||||||
|
"audioRouteRefreshDevices": "Refresh audio devices",
|
||||||
|
"audioRouteCannotSelect": "This output cannot be selected.",
|
||||||
|
"audioRouteChangeFailed": "Could not change audio output.",
|
||||||
|
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
|
||||||
|
"iosAudioResuming": "Audio resuming",
|
||||||
|
"linkTrustTitle": "Open external link?",
|
||||||
|
"linkTrustBody": "You are about to open a link to:\n\n{domain}",
|
||||||
|
"@linkTrustBody": {
|
||||||
|
"placeholders": {
|
||||||
|
"domain": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linkTrustRememberDomain": "Trust all links from this domain",
|
||||||
|
"clientInfoFetchingProfile": "Fetching TeamSpeak profile",
|
||||||
|
"clientInfoLoadingProfile": "Loading profile...",
|
||||||
|
"clientInfoProfileUnavailable": "Profile unavailable",
|
||||||
|
"clientInfoProfileUnavailableBody": "The server did not return profile details for this client.",
|
||||||
|
"clientInfoIdentitySection": "Identity",
|
||||||
|
"clientInfoMembershipSection": "Membership",
|
||||||
|
"clientInfoConnectionSection": "Connection",
|
||||||
|
"clientInfoHistorySection": "History",
|
||||||
|
"clientInfoTransferSection": "Transfer",
|
||||||
|
"clientInfoClientId": "Client ID",
|
||||||
|
"clientInfoDatabaseId": "Database ID",
|
||||||
|
"clientInfoUniqueId": "Unique ID",
|
||||||
|
"clientInfoDescription": "Description",
|
||||||
|
"clientInfoAvatar": "Avatar",
|
||||||
|
"clientInfoServerGroups": "Server groups",
|
||||||
|
"clientInfoChannelGroup": "Channel group",
|
||||||
|
"clientInfoChannelId": "Channel ID",
|
||||||
|
"clientInfoOnline": "Online",
|
||||||
|
"clientInfoIdle": "Idle",
|
||||||
|
"clientInfoPing": "Ping",
|
||||||
|
"clientInfoPingDeviation": "Ping deviation",
|
||||||
|
"clientInfoAddress": "Address",
|
||||||
|
"clientInfoPacketLossClientToServer": "Packet loss C->S",
|
||||||
|
"clientInfoPacketLossServerToClient": "Packet loss S->C",
|
||||||
|
"clientInfoFirstConnected": "First connected",
|
||||||
|
"clientInfoLastConnected": "Last connected",
|
||||||
|
"clientInfoConnections": "Connections",
|
||||||
|
"clientInfoDownloadedMonth": "Downloaded this month",
|
||||||
|
"clientInfoUploadedMonth": "Uploaded this month",
|
||||||
|
"clientInfoDownloadedTotal": "Downloaded total",
|
||||||
|
"clientInfoUploadedTotal": "Uploaded total",
|
||||||
|
"clientInfoUnknown": "Unknown",
|
||||||
|
"clientInfoHidden": "Hidden",
|
||||||
|
"clientInfoNone": "None",
|
||||||
|
"pokeSnackBarClearAction": "Clear",
|
||||||
|
"pokeSnackBarMoreIndicator": "...",
|
||||||
|
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
|
||||||
|
"@pokeSnackBarIncomingNoMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"sender": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
|
||||||
|
"@pokeSnackBarIncomingWithMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"sender": { "type": "String" },
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
||||||
|
"@pokeHistorySelfNoMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"target": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistorySelfWithMessage": "<{time}> You poked \"{target}\" with message: {message}",
|
||||||
|
"@pokeHistorySelfWithMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"target": { "type": "String" },
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistoryIncomingNoMessage": "<{time}> \"{sender}\" pokes you",
|
||||||
|
"@pokeHistoryIncomingNoMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"sender": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistoryIncomingWithMessage": "<{time}> \"{sender}\" pokes you: {message}",
|
||||||
|
"@pokeHistoryIncomingWithMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"sender": { "type": "String" },
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clientVolumeAction": "User Volume",
|
||||||
|
"clientVolumeTitle": "{name} — volume",
|
||||||
|
"@clientVolumeTitle": {
|
||||||
|
"placeholders": { "name": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"clientVolumeLabel": "{percent}%",
|
||||||
|
"@clientVolumeLabel": {
|
||||||
|
"placeholders": { "percent": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"clientVolumeMuteAction": "Mute user",
|
||||||
|
"clientVolumeUnmuteAction": "Unmute user",
|
||||||
|
"clientVolumeResetAction": "Reset to default",
|
||||||
|
"permissionDenied": "Permission Denied"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,245 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "zh",
|
"@@locale": "zh",
|
||||||
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Simplified Chinese translations for the MVP key set. Keys must match app_en.arb; server-provided content is NOT translated (ADR-008 / DEC-015).",
|
"@@x-source-of-truth": "DEC-015 (register v0.9.5). Simplified Chinese for the Beta key set.",
|
||||||
|
|
||||||
"appTitle": "Chanora",
|
"appTitle": "Chanora",
|
||||||
"homeNotProductionReadyBanner": "Alpha 版本——尚未达到生产环境质量。",
|
"homeNotProductionReadyBanner": "Beta 版本——已接通语音收发;尚未达到生产环境质量。",
|
||||||
|
|
||||||
"fieldServerHost": "服务器地址",
|
"fieldServerHost": "服务器地址",
|
||||||
"fieldNickname": "昵称",
|
"fieldNickname": "昵称",
|
||||||
|
"fieldServerPassword": "服务器密码",
|
||||||
|
"fieldServerPasswordHelp": "如果服务器无需密码,留空即可。",
|
||||||
|
"fieldPassword": "密码",
|
||||||
|
"fieldDisplayName": "显示名称",
|
||||||
|
|
||||||
"connectAction": "连接",
|
"connectAction": "连接",
|
||||||
"disconnectAction": "断开连接",
|
"disconnectAction": "离开服务器",
|
||||||
|
"disconnectConfirmTitle": "离开服务器?",
|
||||||
|
"disconnectConfirmBody": "你将从当前服务器断开连接。",
|
||||||
|
"disconnectConfirmCancel": "留下",
|
||||||
|
"disconnectConfirmAction": "离开",
|
||||||
"refreshAction": "刷新",
|
"refreshAction": "刷新",
|
||||||
|
"diagnosticsAction": "诊断信息",
|
||||||
|
"diagnosticsSaveAction": "保存导出",
|
||||||
|
"diagnosticsLiveUpdating": "实时更新中",
|
||||||
|
"shareAction": "分享",
|
||||||
|
"diagnosticsSaved": "诊断导出已保存到 {path}",
|
||||||
|
"aboutAction": "关于",
|
||||||
|
"aboutVersion": "版本 {version}",
|
||||||
|
"aboutAuthor": "作者: Edison Jwa",
|
||||||
|
"aboutNonAffiliation": "Chanora 是独立项目,与 TeamSpeak 之间不存在任何附属、认可、赞助或官方关联关系。",
|
||||||
|
"aboutLicenseHeading": "许可协议",
|
||||||
|
"aboutLicenseBody": "Chanora 采用 Apache License 2.0 或 MIT License 双协议授权,使用者可任选其一。完整协议文本以 LICENSE-APACHE 与 LICENSE-MIT 形式随仓库一同分发。",
|
||||||
|
"aboutThirdPartyHeading": "第三方组件",
|
||||||
|
"aboutThirdPartyBody": "Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。",
|
||||||
|
"copyAction": "复制",
|
||||||
|
"closeAction": "关闭",
|
||||||
|
"openAction": "打开",
|
||||||
|
"cancelAction": "取消",
|
||||||
|
"retryAction": "重试",
|
||||||
|
"chatAction": "聊天",
|
||||||
|
"chatCloseAction": "关闭聊天",
|
||||||
|
"chatNewPrivateAction": "新建私聊",
|
||||||
|
"chatSearchClientsHint": "搜索用户...",
|
||||||
|
"chatDirectMessageAction": "私聊",
|
||||||
|
"chatPokeAction": "戳一下",
|
||||||
|
"clientInfoAction": "信息",
|
||||||
|
"startAudioAction": "启动语音",
|
||||||
|
"pttHoldToTalk": "按住说话",
|
||||||
|
"pttTransmitting": "正在发送…",
|
||||||
|
"pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。",
|
||||||
|
"pttCapabilityBadge": "对讲能力:{level}({backend})",
|
||||||
|
"pttCapabilityBoundKey": "按键:{key}",
|
||||||
|
"pttCapabilityExplainTitle": "对讲能力说明",
|
||||||
|
"pttCapabilityExplainFocusedHeading": "聚焦对讲",
|
||||||
|
"pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。",
|
||||||
|
"pttCapabilityExplainGoGlobalWindows": "在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。",
|
||||||
|
"pttCapabilityExplainGoGlobalMacos": "在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。",
|
||||||
|
"pttCapabilityExplainGoGlobalLinux": "在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。",
|
||||||
|
"pttCapabilityExplainGoGlobalIos": "在 iOS 上,Apple 不向应用开放全局热键。Chanora 使用屏幕上的按住说话按钮,并且仅在应用位于前台时响应。",
|
||||||
|
"pttCapabilityExplainGoGlobalGeneric": "当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。",
|
||||||
|
"pttConfigureAction": "配置",
|
||||||
|
"pttConfigureTitle": "配置对讲按键",
|
||||||
|
"pttConfigurePrompt": "按下您希望用于对讲的按键或鼠标侧键。",
|
||||||
|
"pttConfigureWaiting": "(等待输入…)",
|
||||||
|
"pttConfigureCaptured": "已捕获",
|
||||||
|
"pttConfigurePrivacyNote": "Chanora 不会记录具体的按键值。本对话框只会向应用提交输入类别(键盘 / 鼠标侧键)以及一个跨平台标签。",
|
||||||
|
"pttConfigureSaveAction": "保存",
|
||||||
|
"pttConfigurePortalRedirect": "您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。",
|
||||||
|
"inputMuteAction": "静音麦克风",
|
||||||
|
"inputUnmuteAction": "取消麦克风静音",
|
||||||
|
"outputMuteAction": "静音扬声器",
|
||||||
|
"outputUnmuteAction": "取消扬声器静音",
|
||||||
|
"joinChannelAction": "加入频道",
|
||||||
|
"leaveChannelAction": "离开频道",
|
||||||
|
"channelPasswordTitle": "频道密码",
|
||||||
|
"bookmarksHeading": "书签",
|
||||||
|
"bookmarksEmpty": "尚无书签。先在上方填写服务器,然后点击“保存书签”。",
|
||||||
|
"bookmarkAddAction": "保存书签",
|
||||||
|
"bookmarkAddTitle": "保存书签",
|
||||||
|
"bookmarkDeleteAction": "删除书签",
|
||||||
|
|
||||||
"statusIdle": "未连接",
|
"statusIdle": "未连接",
|
||||||
"statusConnecting": "正在连接…",
|
"statusConnecting": "正在连接…",
|
||||||
"statusConnected": "已连接到 {server}",
|
"statusConnected": "已连接到 {server}",
|
||||||
"statusError": "错误:{message}",
|
"statusError": "错误:{message}",
|
||||||
|
|
||||||
|
"statusReconnecting": "正在重新连接……第 {attempt} 次尝试,{delay} 秒后",
|
||||||
|
"statusConnectionLost": "连接已断开:{reason}",
|
||||||
|
|
||||||
|
"audioStatsLine": "发送 {sent} 帧 • 接收 {received} 帧 • 麦克风 {ptt}",
|
||||||
|
|
||||||
"channelsHeading": "频道",
|
"channelsHeading": "频道",
|
||||||
"clientsHeading": "在线用户",
|
"clientsHeading": "在线用户",
|
||||||
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线"
|
"serverWelcomeHeading": "服务器欢迎信息",
|
||||||
|
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线",
|
||||||
|
|
||||||
|
"voiceModePtt": "按键说话",
|
||||||
|
"voiceModeContinuous": "持续发送",
|
||||||
|
"voiceModeVoiceActivity": "语音激活",
|
||||||
|
"voiceModeComingSoon": "即将推出",
|
||||||
|
"voiceModeLabel": "发送模式",
|
||||||
|
"voiceReleaseTailLabel": "释放延迟",
|
||||||
|
"voiceReleaseTailHint": "毫秒",
|
||||||
|
"voiceLeaveAction": "离开语音",
|
||||||
|
"voiceHardMuteLabel": "麦克风静音",
|
||||||
|
"voiceOutputMuteLabel": "扬声器静音",
|
||||||
|
"voiceSettingsTitle": "语音设置",
|
||||||
|
"voiceBindKeyAction": "绑定 PTT 按键",
|
||||||
|
"voiceBoundKeyLabel": "已绑定按键",
|
||||||
|
"voicePttHoldHint": "按住按钮",
|
||||||
|
"voiceMicOn": "开启",
|
||||||
|
"voiceMicOff": "关闭",
|
||||||
|
"voiceSheetTitle": "语音",
|
||||||
|
"audioOutputLabel": "音频输出",
|
||||||
|
"audioRouteSpeaker": "扬声器",
|
||||||
|
"audioRouteReceiver": "听筒",
|
||||||
|
"audioRouteBluetooth": "蓝牙",
|
||||||
|
"audioRouteWiredHeadset": "有线耳机",
|
||||||
|
"audioRouteCarAudio": "车载音频",
|
||||||
|
"audioRouteAirplay": "AirPlay",
|
||||||
|
"audioRouteUnknown": "未知",
|
||||||
|
"channelJoinAlreadyIn": "您已在此频道中。",
|
||||||
|
"channelJoinFailedPermission": "权限不足,无法加入此频道。",
|
||||||
|
"channelJoinFailedPassword": "频道密码错误。",
|
||||||
|
"channelJoinFailedFull": "频道已满。",
|
||||||
|
"channelJoinFailedFamilyFull": "频道家族人数已达上限。",
|
||||||
|
"channelJoinFailedPrivate": "此频道为私有频道。",
|
||||||
|
"channelJoinFailedTimeout": "无法加入频道:服务器响应超时。",
|
||||||
|
"channelJoinFailedFlooding": "切换频道过于频繁,请稍后再试。",
|
||||||
|
"channelJoinFailedGeneric": "无法加入频道:{message}",
|
||||||
|
"@channelJoinFailedGeneric": {
|
||||||
|
"placeholders": {
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networkPermissionTitle": "需要网络权限",
|
||||||
|
"networkPermissionBody": "Chanora 需要网络访问权限。请前往系统设置 → 隐私与安全性 → 本地网络,启用 Chanora,然后重试。",
|
||||||
|
"networkPermissionOpenSettings": "打开系统设置",
|
||||||
|
"microphonePermissionTitle": "需要麦克风权限",
|
||||||
|
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
|
||||||
|
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
|
||||||
|
"permissionGrantAction": "授权",
|
||||||
|
"startupPermissionsTitle": "权限",
|
||||||
|
"startupPermissionsBody": "Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。",
|
||||||
|
"startupPermissionsNotNow": "暂不",
|
||||||
|
"startupPermissionsAllow": "允许",
|
||||||
|
"audioRouteSystemDefault": "系统默认",
|
||||||
|
"audioRouteEarpiece": "听筒",
|
||||||
|
"audioRouteUsbHeadset": "USB 耳机",
|
||||||
|
"audioRouteOtherDevice": "其他设备",
|
||||||
|
"audioRouteRefreshDevices": "刷新音频设备",
|
||||||
|
"audioRouteCannotSelect": "无法选择此输出设备。",
|
||||||
|
"audioRouteChangeFailed": "无法切换音频输出。",
|
||||||
|
"iosAudioInterrupted": "系统已中断音频(电话通话)",
|
||||||
|
"iosAudioResuming": "音频正在恢复",
|
||||||
|
"linkTrustTitle": "打开外部链接?",
|
||||||
|
"linkTrustBody": "你将打开指向以下域名的链接:\n\n{domain}",
|
||||||
|
"linkTrustRememberDomain": "信任来自此域名的所有链接",
|
||||||
|
"clientInfoFetchingProfile": "正在获取 TeamSpeak 资料",
|
||||||
|
"clientInfoLoadingProfile": "正在加载资料…",
|
||||||
|
"clientInfoProfileUnavailable": "资料不可用",
|
||||||
|
"clientInfoProfileUnavailableBody": "服务器没有返回此用户的资料详情。",
|
||||||
|
"clientInfoIdentitySection": "身份",
|
||||||
|
"clientInfoMembershipSection": "成员关系",
|
||||||
|
"clientInfoConnectionSection": "连接",
|
||||||
|
"clientInfoHistorySection": "历史",
|
||||||
|
"clientInfoTransferSection": "传输",
|
||||||
|
"clientInfoClientId": "用户 ID",
|
||||||
|
"clientInfoDatabaseId": "数据库 ID",
|
||||||
|
"clientInfoUniqueId": "唯一 ID",
|
||||||
|
"clientInfoDescription": "描述",
|
||||||
|
"clientInfoAvatar": "头像",
|
||||||
|
"clientInfoServerGroups": "服务器组",
|
||||||
|
"clientInfoChannelGroup": "频道组",
|
||||||
|
"clientInfoChannelId": "频道 ID",
|
||||||
|
"clientInfoOnline": "在线时长",
|
||||||
|
"clientInfoIdle": "空闲",
|
||||||
|
"clientInfoPing": "延迟",
|
||||||
|
"clientInfoPingDeviation": "延迟偏差",
|
||||||
|
"clientInfoAddress": "地址",
|
||||||
|
"clientInfoPacketLossClientToServer": "丢包 C->S",
|
||||||
|
"clientInfoPacketLossServerToClient": "丢包 S->C",
|
||||||
|
"clientInfoFirstConnected": "首次连接",
|
||||||
|
"clientInfoLastConnected": "上次连接",
|
||||||
|
"clientInfoConnections": "连接次数",
|
||||||
|
"clientInfoDownloadedMonth": "本月下载",
|
||||||
|
"clientInfoUploadedMonth": "本月上传",
|
||||||
|
"clientInfoDownloadedTotal": "总下载",
|
||||||
|
"clientInfoUploadedTotal": "总上传",
|
||||||
|
"clientInfoUnknown": "未知",
|
||||||
|
"clientInfoHidden": "隐藏",
|
||||||
|
"clientInfoNone": "无",
|
||||||
|
"pokeSnackBarClearAction": "清除",
|
||||||
|
"pokeSnackBarMoreIndicator": "...",
|
||||||
|
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
|
||||||
|
"@pokeSnackBarIncomingNoMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"sender": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
|
||||||
|
"@pokeSnackBarIncomingWithMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"sender": { "type": "String" },
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
||||||
|
"@pokeHistorySelfNoMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"target": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistorySelfWithMessage": "<{time}> 你戳了“{target}”一下,消息:{message}",
|
||||||
|
"@pokeHistorySelfWithMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"target": { "type": "String" },
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistoryIncomingNoMessage": "<{time}> “{sender}”戳了你一下",
|
||||||
|
"@pokeHistoryIncomingNoMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"sender": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeHistoryIncomingWithMessage": "<{time}> “{sender}”戳了你一下:{message}",
|
||||||
|
"@pokeHistoryIncomingWithMessage": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": { "type": "String" },
|
||||||
|
"sender": { "type": "String" },
|
||||||
|
"message": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clientVolumeAction": "用户音量",
|
||||||
|
"clientVolumeTitle": "{name} — 音量",
|
||||||
|
"clientVolumeLabel": "{percent}%",
|
||||||
|
"clientVolumeMuteAction": "静音该用户",
|
||||||
|
"clientVolumeUnmuteAction": "取消静音",
|
||||||
|
"clientVolumeResetAction": "恢复默认",
|
||||||
|
"permissionDenied": "权限被拒绝"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AppL10nEn extends AppL10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeNotProductionReadyBanner =>
|
String get homeNotProductionReadyBanner =>
|
||||||
'Alpha build — not production ready.';
|
'Beta build — voice in/out wired; not production ready.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fieldServerHost => 'Server address';
|
String get fieldServerHost => 'Server address';
|
||||||
@@ -21,15 +21,240 @@ class AppL10nEn extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get fieldNickname => 'Nickname';
|
String get fieldNickname => 'Nickname';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldServerPassword => 'Server password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldServerPasswordHelp =>
|
||||||
|
'Leave blank if the server does not require one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldPassword => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldDisplayName => 'Display name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connectAction => 'Connect';
|
String get connectAction => 'Connect';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disconnectAction => 'Disconnect';
|
String get disconnectAction => 'Leave server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmTitle => 'Leave server?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmBody =>
|
||||||
|
'You will disconnect from the current server.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmCancel => 'Stay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmAction => 'Leave';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get refreshAction => 'Refresh';
|
String get refreshAction => 'Refresh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsAction => 'Diagnostics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsSaveAction => 'Save export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsLiveUpdating => 'Live updating';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareAction => 'Share';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagnosticsSaved(String path) {
|
||||||
|
return 'Diagnostic export saved to $path';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutAction => 'About';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String aboutVersion(String version) {
|
||||||
|
return 'Version $version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutAuthor => 'Author: Edison Jwa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutNonAffiliation =>
|
||||||
|
'Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutLicenseHeading => 'License';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutLicenseBody =>
|
||||||
|
'Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutThirdPartyHeading => 'Third-party software';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutThirdPartyBody =>
|
||||||
|
'Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyAction => 'Copy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get closeAction => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openAction => 'Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelAction => 'Cancel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryAction => 'Retry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatAction => 'Chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatCloseAction => 'Close chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatNewPrivateAction => 'New private chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatSearchClientsHint => 'Search clients...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatDirectMessageAction => 'Private message';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatPokeAction => 'Poke';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoAction => 'Info';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startAudioAction => 'Start audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttHoldToTalk => 'Hold to talk';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttTransmitting => 'Transmitting…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttHoldToTalkSemanticsHint =>
|
||||||
|
'Press and hold to transmit voice; release to stop.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pttCapabilityBadge(String level, String backend) {
|
||||||
|
return 'PTT: $level ($backend)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pttCapabilityBoundKey(String key) {
|
||||||
|
return 'Key: $key';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainTitle => 'Push-to-Talk capability';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainFocusedHeading => 'Focused PTT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainFocusedBody =>
|
||||||
|
'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalWindows =>
|
||||||
|
'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalMacos =>
|
||||||
|
'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalLinux =>
|
||||||
|
'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalIos =>
|
||||||
|
'On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalGeneric =>
|
||||||
|
'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureAction => 'Configure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureTitle => 'Configure Push-to-Talk binding';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigurePrompt =>
|
||||||
|
'Press the key or mouse side button you want to use for Push-to-Talk.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureWaiting => '(waiting for input…)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureCaptured => 'Captured';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigurePrivacyNote =>
|
||||||
|
'Chanora never logs the actual key value. Only the input class (keyboard / mouse-side-button) and a platform-neutral label leave this dialog.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureSaveAction => 'Save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigurePortalRedirect =>
|
||||||
|
'Your desktop environment will open its own shortcut dialog. Pick the key you want to use for Push-to-Talk.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inputMuteAction => 'Mute mic';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inputUnmuteAction => 'Unmute mic';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get outputMuteAction => 'Mute speaker';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get outputUnmuteAction => 'Unmute speaker';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get joinChannelAction => 'Join channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get leaveChannelAction => 'Leave channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelPasswordTitle => 'Channel password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksHeading => 'Bookmarks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksEmpty =>
|
||||||
|
'No bookmarks yet. Enter a server above and tap \"Save bookmark\".';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkAddAction => 'Save bookmark';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkAddTitle => 'Save bookmark';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteAction => 'Delete bookmark';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statusIdle => 'Not connected';
|
String get statusIdle => 'Not connected';
|
||||||
|
|
||||||
@@ -46,14 +271,386 @@ class AppL10nEn extends AppL10n {
|
|||||||
return 'Error: $message';
|
return 'Error: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String statusReconnecting(int attempt, int delay) {
|
||||||
|
return 'Reconnecting… attempt $attempt, in ${delay}s';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String statusConnectionLost(String reason) {
|
||||||
|
return 'Connection lost: $reason';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audioStatsLine(int sent, int received, String ptt) {
|
||||||
|
return 'TX $sent frames • RX $received frames • Mic $ptt';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelsHeading => 'Channels';
|
String get channelsHeading => 'Channels';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get clientsHeading => 'Online clients';
|
String get clientsHeading => 'Online clients';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverWelcomeHeading => 'Server welcome message';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String countChannelsAndClients(int channels, int clients) {
|
String countChannelsAndClients(int channels, int clients) {
|
||||||
return '$channels channels • $clients online';
|
return '$channels channels • $clients online';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModePtt => 'PTT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeContinuous => 'Continuous';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeVoiceActivity => 'Voice activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeComingSoon => 'Coming soon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeLabel => 'Transmit mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceReleaseTailLabel => 'Release tail';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceReleaseTailHint => 'ms';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceLeaveAction => 'Leave voice';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceHardMuteLabel => 'Mute microphone';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceOutputMuteLabel => 'Mute speakers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceSettingsTitle => 'Voice settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceBindKeyAction => 'Bind PTT key';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceBoundKeyLabel => 'Bound key';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voicePttHoldHint => 'Hold the button';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceMicOn => 'on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceMicOff => 'off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceSheetTitle => 'Voice';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioOutputLabel => 'Audio output';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteSpeaker => 'Speaker';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteReceiver => 'iPhone receiver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteBluetooth => 'Bluetooth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteWiredHeadset => 'Wired headset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteCarAudio => 'Car audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteAirplay => 'AirPlay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteUnknown => 'Unknown';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinAlreadyIn => 'Already in this channel.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedPermission =>
|
||||||
|
'Insufficient permission to join this channel.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedPassword => 'Wrong channel password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFull => 'Channel is full.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFamilyFull => 'Channel family limit reached.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedPrivate => 'This channel is private.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedTimeout =>
|
||||||
|
'Could not join channel: the server didn\'t respond in time.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFlooding =>
|
||||||
|
'Too many channel switches. Please wait a moment.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String channelJoinFailedGeneric(String message) {
|
||||||
|
return 'Could not join channel: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get networkPermissionTitle => 'Network Permission Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get networkPermissionBody =>
|
||||||
|
'Chanora needs permission to access the network. On macOS, go to System Settings → Privacy & Security → Local Network and enable Chanora, then try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get networkPermissionOpenSettings => 'Open System Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionTitle => 'Microphone Permission Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionBody =>
|
||||||
|
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionRequiredForVoice =>
|
||||||
|
'Microphone permission is required for voice transmission.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get permissionGrantAction => 'Grant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsTitle => 'Permissions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsBody =>
|
||||||
|
'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsNotNow => 'Not now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsAllow => 'Allow';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteSystemDefault => 'System default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteEarpiece => 'Earpiece';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteUsbHeadset => 'USB headset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteOtherDevice => 'Other device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteRefreshDevices => 'Refresh audio devices';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteCannotSelect => 'This output cannot be selected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteChangeFailed => 'Could not change audio output.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioInterrupted => 'Audio interrupted by system (phone call)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioResuming => 'Audio resuming';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get linkTrustTitle => 'Open external link?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String linkTrustBody(String domain) {
|
||||||
|
return 'You are about to open a link to:\n\n$domain';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get linkTrustRememberDomain => 'Trust all links from this domain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoFetchingProfile => 'Fetching TeamSpeak profile';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoLoadingProfile => 'Loading profile...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoProfileUnavailable => 'Profile unavailable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoProfileUnavailableBody =>
|
||||||
|
'The server did not return profile details for this client.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoIdentitySection => 'Identity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoMembershipSection => 'Membership';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoConnectionSection => 'Connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoHistorySection => 'History';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoTransferSection => 'Transfer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoClientId => 'Client ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDatabaseId => 'Database ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUniqueId => 'Unique ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDescription => 'Description';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoAvatar => 'Avatar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoServerGroups => 'Server groups';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoChannelGroup => 'Channel group';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoChannelId => 'Channel ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoOnline => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoIdle => 'Idle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPing => 'Ping';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPingDeviation => 'Ping deviation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoAddress => 'Address';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPacketLossClientToServer => 'Packet loss C->S';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPacketLossServerToClient => 'Packet loss S->C';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoFirstConnected => 'First connected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoLastConnected => 'Last connected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoConnections => 'Connections';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDownloadedMonth => 'Downloaded this month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUploadedMonth => 'Uploaded this month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDownloadedTotal => 'Downloaded total';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUploadedTotal => 'Uploaded total';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUnknown => 'Unknown';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoHidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSnackBarClearAction => 'Clear';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSnackBarMoreIndicator => '...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSnackBarIncomingNoMessage(String sender) {
|
||||||
|
return '$sender pokes you';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
||||||
|
return '$sender pokes you: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistorySelfNoMessage(String time, String target) {
|
||||||
|
return '<$time> You poked \"$target\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistorySelfWithMessage(
|
||||||
|
String time,
|
||||||
|
String target,
|
||||||
|
String message,
|
||||||
|
) {
|
||||||
|
return '<$time> You poked \"$target\" with message: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistoryIncomingNoMessage(String time, String sender) {
|
||||||
|
return '<$time> \"$sender\" pokes you';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistoryIncomingWithMessage(
|
||||||
|
String time,
|
||||||
|
String sender,
|
||||||
|
String message,
|
||||||
|
) {
|
||||||
|
return '<$time> \"$sender\" pokes you: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeAction => 'User Volume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String clientVolumeTitle(String name) {
|
||||||
|
return '$name — volume';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String clientVolumeLabel(int percent) {
|
||||||
|
return '$percent%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeMuteAction => 'Mute user';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeUnmuteAction => 'Unmute user';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeResetAction => 'Reset to default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get permissionDenied => 'Permission Denied';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class AppL10nZh extends AppL10n {
|
|||||||
String get appTitle => 'Chanora';
|
String get appTitle => 'Chanora';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeNotProductionReadyBanner => 'Alpha 版本——尚未达到生产环境质量。';
|
String get homeNotProductionReadyBanner => 'Beta 版本——已接通语音收发;尚未达到生产环境质量。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fieldServerHost => '服务器地址';
|
String get fieldServerHost => '服务器地址';
|
||||||
@@ -20,15 +20,234 @@ class AppL10nZh extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get fieldNickname => '昵称';
|
String get fieldNickname => '昵称';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldServerPassword => '服务器密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldServerPasswordHelp => '如果服务器无需密码,留空即可。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldPassword => '密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fieldDisplayName => '显示名称';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connectAction => '连接';
|
String get connectAction => '连接';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disconnectAction => '断开连接';
|
String get disconnectAction => '离开服务器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmTitle => '离开服务器?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmBody => '你将从当前服务器断开连接。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmCancel => '留下';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disconnectConfirmAction => '离开';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get refreshAction => '刷新';
|
String get refreshAction => '刷新';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsAction => '诊断信息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsSaveAction => '保存导出';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsLiveUpdating => '实时更新中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareAction => '分享';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagnosticsSaved(String path) {
|
||||||
|
return '诊断导出已保存到 $path';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutAction => '关于';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String aboutVersion(String version) {
|
||||||
|
return '版本 $version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutAuthor => '作者: Edison Jwa';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutNonAffiliation =>
|
||||||
|
'Chanora 是独立项目,与 TeamSpeak 之间不存在任何附属、认可、赞助或官方关联关系。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutLicenseHeading => '许可协议';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutLicenseBody =>
|
||||||
|
'Chanora 采用 Apache License 2.0 或 MIT License 双协议授权,使用者可任选其一。完整协议文本以 LICENSE-APACHE 与 LICENSE-MIT 形式随仓库一同分发。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutThirdPartyHeading => '第三方组件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutThirdPartyBody =>
|
||||||
|
'Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get copyAction => '复制';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get closeAction => '关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openAction => '打开';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelAction => '取消';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryAction => '重试';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatAction => '聊天';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatCloseAction => '关闭聊天';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatNewPrivateAction => '新建私聊';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatSearchClientsHint => '搜索用户...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatDirectMessageAction => '私聊';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chatPokeAction => '戳一下';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoAction => '信息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startAudioAction => '启动语音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttHoldToTalk => '按住说话';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttTransmitting => '正在发送…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttHoldToTalkSemanticsHint => '按住进行语音发送,松开停止。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pttCapabilityBadge(String level, String backend) {
|
||||||
|
return '对讲能力:$level($backend)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pttCapabilityBoundKey(String key) {
|
||||||
|
return '按键:$key';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainTitle => '对讲能力说明';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainFocusedHeading => '聚焦对讲';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainFocusedBody =>
|
||||||
|
'Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalWindows =>
|
||||||
|
'在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalMacos =>
|
||||||
|
'在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalLinux =>
|
||||||
|
'在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalIos =>
|
||||||
|
'在 iOS 上,Apple 不向应用开放全局热键。Chanora 使用屏幕上的按住说话按钮,并且仅在应用位于前台时响应。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttCapabilityExplainGoGlobalGeneric =>
|
||||||
|
'当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureAction => '配置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureTitle => '配置对讲按键';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigurePrompt => '按下您希望用于对讲的按键或鼠标侧键。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureWaiting => '(等待输入…)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureCaptured => '已捕获';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigurePrivacyNote =>
|
||||||
|
'Chanora 不会记录具体的按键值。本对话框只会向应用提交输入类别(键盘 / 鼠标侧键)以及一个跨平台标签。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigureSaveAction => '保存';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pttConfigurePortalRedirect => '您的桌面环境将打开自带的快捷键对话框,请在其中选择用于对讲的按键。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inputMuteAction => '静音麦克风';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inputUnmuteAction => '取消麦克风静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get outputMuteAction => '静音扬声器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get outputUnmuteAction => '取消扬声器静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get joinChannelAction => '加入频道';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get leaveChannelAction => '离开频道';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelPasswordTitle => '频道密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksHeading => '书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksEmpty => '尚无书签。先在上方填写服务器,然后点击“保存书签”。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkAddAction => '保存书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkAddTitle => '保存书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteAction => '删除书签';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statusIdle => '未连接';
|
String get statusIdle => '未连接';
|
||||||
|
|
||||||
@@ -45,14 +264,381 @@ class AppL10nZh extends AppL10n {
|
|||||||
return '错误:$message';
|
return '错误:$message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String statusReconnecting(int attempt, int delay) {
|
||||||
|
return '正在重新连接……第 $attempt 次尝试,$delay 秒后';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String statusConnectionLost(String reason) {
|
||||||
|
return '连接已断开:$reason';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String audioStatsLine(int sent, int received, String ptt) {
|
||||||
|
return '发送 $sent 帧 • 接收 $received 帧 • 麦克风 $ptt';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get channelsHeading => '频道';
|
String get channelsHeading => '频道';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get clientsHeading => '在线用户';
|
String get clientsHeading => '在线用户';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverWelcomeHeading => '服务器欢迎信息';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String countChannelsAndClients(int channels, int clients) {
|
String countChannelsAndClients(int channels, int clients) {
|
||||||
return '$channels 个频道 • $clients 在线';
|
return '$channels 个频道 • $clients 在线';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModePtt => '按键说话';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeContinuous => '持续发送';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeVoiceActivity => '语音激活';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeComingSoon => '即将推出';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceModeLabel => '发送模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceReleaseTailLabel => '释放延迟';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceReleaseTailHint => '毫秒';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceLeaveAction => '离开语音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceHardMuteLabel => '麦克风静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceOutputMuteLabel => '扬声器静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceSettingsTitle => '语音设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceBindKeyAction => '绑定 PTT 按键';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceBoundKeyLabel => '已绑定按键';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voicePttHoldHint => '按住按钮';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceMicOn => '开启';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceMicOff => '关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceSheetTitle => '语音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioOutputLabel => '音频输出';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteSpeaker => '扬声器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteReceiver => '听筒';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteBluetooth => '蓝牙';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteWiredHeadset => '有线耳机';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteCarAudio => '车载音频';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteAirplay => 'AirPlay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteUnknown => '未知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinAlreadyIn => '您已在此频道中。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedPermission => '权限不足,无法加入此频道。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedPassword => '频道密码错误。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFull => '频道已满。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFamilyFull => '频道家族人数已达上限。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedPrivate => '此频道为私有频道。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedTimeout => '无法加入频道:服务器响应超时。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFlooding => '切换频道过于频繁,请稍后再试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String channelJoinFailedGeneric(String message) {
|
||||||
|
return '无法加入频道:$message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get networkPermissionTitle => '需要网络权限';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get networkPermissionBody =>
|
||||||
|
'Chanora 需要网络访问权限。请前往系统设置 → 隐私与安全性 → 本地网络,启用 Chanora,然后重试。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get networkPermissionOpenSettings => '打开系统设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionTitle => '需要麦克风权限';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionBody =>
|
||||||
|
'Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionRequiredForVoice => '语音发送需要麦克风权限。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get permissionGrantAction => '授权';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsTitle => '权限';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsBody =>
|
||||||
|
'Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsNotNow => '暂不';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startupPermissionsAllow => '允许';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteSystemDefault => '系统默认';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteEarpiece => '听筒';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteUsbHeadset => 'USB 耳机';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteOtherDevice => '其他设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteRefreshDevices => '刷新音频设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteCannotSelect => '无法选择此输出设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteChangeFailed => '无法切换音频输出。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioInterrupted => '系统已中断音频(电话通话)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioResuming => '音频正在恢复';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get linkTrustTitle => '打开外部链接?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String linkTrustBody(String domain) {
|
||||||
|
return '你将打开指向以下域名的链接:\n\n$domain';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get linkTrustRememberDomain => '信任来自此域名的所有链接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoFetchingProfile => '正在获取 TeamSpeak 资料';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoLoadingProfile => '正在加载资料…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoProfileUnavailable => '资料不可用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoProfileUnavailableBody => '服务器没有返回此用户的资料详情。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoIdentitySection => '身份';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoMembershipSection => '成员关系';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoConnectionSection => '连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoHistorySection => '历史';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoTransferSection => '传输';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoClientId => '用户 ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDatabaseId => '数据库 ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUniqueId => '唯一 ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDescription => '描述';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoAvatar => '头像';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoServerGroups => '服务器组';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoChannelGroup => '频道组';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoChannelId => '频道 ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoOnline => '在线时长';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoIdle => '空闲';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPing => '延迟';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPingDeviation => '延迟偏差';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoAddress => '地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPacketLossClientToServer => '丢包 C->S';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoPacketLossServerToClient => '丢包 S->C';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoFirstConnected => '首次连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoLastConnected => '上次连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoConnections => '连接次数';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDownloadedMonth => '本月下载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUploadedMonth => '本月上传';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoDownloadedTotal => '总下载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUploadedTotal => '总上传';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoUnknown => '未知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoHidden => '隐藏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientInfoNone => '无';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSnackBarClearAction => '清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSnackBarMoreIndicator => '...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSnackBarIncomingNoMessage(String sender) {
|
||||||
|
return '$sender 戳了你一下';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
||||||
|
return '$sender 戳了你一下:$message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistorySelfNoMessage(String time, String target) {
|
||||||
|
return '<$time> 你戳了“$target”一下。';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistorySelfWithMessage(
|
||||||
|
String time,
|
||||||
|
String target,
|
||||||
|
String message,
|
||||||
|
) {
|
||||||
|
return '<$time> 你戳了“$target”一下,消息:$message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistoryIncomingNoMessage(String time, String sender) {
|
||||||
|
return '<$time> “$sender”戳了你一下';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeHistoryIncomingWithMessage(
|
||||||
|
String time,
|
||||||
|
String sender,
|
||||||
|
String message,
|
||||||
|
) {
|
||||||
|
return '<$time> “$sender”戳了你一下:$message';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeAction => '用户音量';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String clientVolumeTitle(String name) {
|
||||||
|
return '$name — 音量';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String clientVolumeLabel(int percent) {
|
||||||
|
return '$percent%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeMuteAction => '静音该用户';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeUnmuteAction => '取消静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clientVolumeResetAction => '恢复默认';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get permissionDenied => '权限被拒绝';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
class AndroidAudioOutputDevice {
|
||||||
|
const AndroidAudioOutputDevice({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isAvailableForCommunication,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String type;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isAvailableForCommunication;
|
||||||
|
|
||||||
|
factory AndroidAudioOutputDevice.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
return AndroidAudioOutputDevice(
|
||||||
|
id: map['id']?.toString() ?? '',
|
||||||
|
name: map['name']?.toString() ?? '',
|
||||||
|
type: map['type']?.toString() ?? 'unknown',
|
||||||
|
isSelected: map['isSelected'] == true,
|
||||||
|
isAvailableForCommunication: map['isAvailableForCommunication'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AndroidAudioOutputDevice> parseAndroidAudioOutputDevices(
|
||||||
|
List<dynamic> raw,
|
||||||
|
) {
|
||||||
|
return raw
|
||||||
|
.whereType<Map<dynamic, dynamic>>()
|
||||||
|
.map(AndroidAudioOutputDevice.fromMap)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidAudioOutputDevice? selectedAndroidAudioOutputDevice(
|
||||||
|
List<AndroidAudioOutputDevice> devices,
|
||||||
|
) {
|
||||||
|
return devices.where((device) => device.isSelected).firstOrNull;
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
/// SDD-106 `AndroidPermissionRequester` — Dart integration layer.
|
||||||
|
///
|
||||||
|
/// Trace:
|
||||||
|
/// - SDD-106 (Android runtime acquisition of `RECORD_AUDIO` with
|
||||||
|
/// listen-only fallback; bridge event surface §5).
|
||||||
|
/// - SRS-209 (runtime microphone permission acquisition; fail-safe
|
||||||
|
/// to listen-only on denial; user-visible path to grant).
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// * Subscribe to the Kotlin-side `MethodChannel`
|
||||||
|
/// `app.chanora/android_permissions` for `permissionStateChanged`
|
||||||
|
/// invocations emitted by `AndroidPermissionRequester.kt`
|
||||||
|
/// (Kotlin → Dart).
|
||||||
|
/// * Provide imperative Dart → Kotlin entry points
|
||||||
|
/// `requestRecordAudio` and `openAppSettings` so the UI can drive
|
||||||
|
/// the runtime request flow per SDD-106 §1–§3.
|
||||||
|
/// * Expose the latest resolved state as a [ValueListenable] so UI
|
||||||
|
/// surfaces (the listen-only banner per SRS-209; the pre-`voice_join`
|
||||||
|
/// gate per SDD-106 §1) can react without polling.
|
||||||
|
///
|
||||||
|
/// ## Non-Android short-circuit
|
||||||
|
///
|
||||||
|
/// On Linux / macOS / Windows / iOS / web, RECORD_AUDIO is not gated
|
||||||
|
/// by this channel (desktop has no Android runtime-permission concept;
|
||||||
|
/// iOS uses AVAudioSession authorisation which is owned by the audio
|
||||||
|
/// engine itself per SDD-101). The MethodChannel is therefore never
|
||||||
|
/// constructed off-Android. The state listenable stays at
|
||||||
|
/// [AndroidRecordAudioPermissionState.granted] so the voice-join gate
|
||||||
|
/// (in `main.dart`) becomes a no-op on those platforms.
|
||||||
|
///
|
||||||
|
/// ## No global statics
|
||||||
|
///
|
||||||
|
/// Following the pattern established by `BackIntentService`
|
||||||
|
/// (SDD-028), this class is constructor-injected. The host app
|
||||||
|
/// instantiates one instance at startup and passes it through the
|
||||||
|
/// widget tree.
|
||||||
|
///
|
||||||
|
/// ## Bridge event surface (SDD-106 §5)
|
||||||
|
///
|
||||||
|
/// SDD-106 §5 calls for a `BridgeEvent::PermissionState` variant on
|
||||||
|
/// the existing Rust → Dart event stream so the audio engine can
|
||||||
|
/// observe the resolved state for the transmit-clamp described in
|
||||||
|
/// SDD-106 §6. That Rust-side variant is intentionally deferred to a
|
||||||
|
/// follow-up Wave touching `chanora_bridge`; the current Wave 2B
|
||||||
|
/// Dart-only path is sufficient for the UI obligations of SRS-209
|
||||||
|
/// (listen-only banner; path to grant). See the structured handoff
|
||||||
|
/// note attached to this task for the rationale.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// MethodChannel name shared with Kotlin `MethodChannels.ANDROID_PERMISSIONS`.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5.
|
||||||
|
@visibleForTesting
|
||||||
|
const String androidPermissionsChannelName = 'app.chanora/android_permissions';
|
||||||
|
|
||||||
|
/// Inbound (Kotlin → Dart) method invoked whenever the resolved
|
||||||
|
/// `RECORD_AUDIO` permission state transitions.
|
||||||
|
///
|
||||||
|
/// Payload schema: `{permission: String, state: String}` where
|
||||||
|
/// `state` is one of `"Granted"`, `"Denied"`, `"PermanentlyDenied"`.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5; mirrors Kotlin
|
||||||
|
/// `MethodChannels.METHOD_PERMISSION_STATE_CHANGED`.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodPermissionStateChanged = 'permissionStateChanged';
|
||||||
|
|
||||||
|
/// Outbound (Dart → Kotlin) method that asks the Android requester to
|
||||||
|
/// (re-)prompt for `RECORD_AUDIO`. The result is delivered
|
||||||
|
/// asynchronously via [methodPermissionStateChanged].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §1.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestRecordAudio = 'requestRecordAudio';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestStartupPermissions = 'requestStartupPermissions';
|
||||||
|
|
||||||
|
/// Outbound (Dart → Kotlin) method that deep-links to the application's
|
||||||
|
/// Android Settings page so the user can re-grant a permanently-denied
|
||||||
|
/// permission.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §3.
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodOpenAppSettings = 'openAppSettings';
|
||||||
|
|
||||||
|
/// Discrete states surfaced to the Dart UI.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5 (state machine).
|
||||||
|
enum AndroidRecordAudioPermissionState {
|
||||||
|
/// Permission granted; microphone capture is allowed.
|
||||||
|
granted,
|
||||||
|
|
||||||
|
/// Permission denied but the user may still be re-prompted by
|
||||||
|
/// invoking [AndroidPermissionsService.ensureRecordAudio] again.
|
||||||
|
denied,
|
||||||
|
|
||||||
|
/// Permission denied with "Don't ask again" or revoked from system
|
||||||
|
/// Settings. The UI must deep-link to app settings via
|
||||||
|
/// [AndroidPermissionsService.openAppSettings] (SDD-106 §3).
|
||||||
|
permanentlyDenied,
|
||||||
|
|
||||||
|
/// No resolved state yet (cold launch before the first emission, or
|
||||||
|
/// non-Android host before short-circuit). The voice-join gate
|
||||||
|
/// treats this as "request before joining".
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AndroidPermissionKind {
|
||||||
|
recordAudio('android.permission.RECORD_AUDIO'),
|
||||||
|
bluetoothConnect('android.permission.BLUETOOTH_CONNECT'),
|
||||||
|
postNotifications('android.permission.POST_NOTIFICATIONS');
|
||||||
|
|
||||||
|
const AndroidPermissionKind(this.permission);
|
||||||
|
final String permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps the Kotlin-side `PermissionState` string into the Dart enum.
|
||||||
|
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Granted':
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
case 'Denied':
|
||||||
|
return AndroidRecordAudioPermissionState.denied;
|
||||||
|
case 'PermanentlyDenied':
|
||||||
|
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||||
|
default:
|
||||||
|
return AndroidRecordAudioPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dart-side integration for `AndroidPermissionRequester` (SDD-106).
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106, SRS-209.
|
||||||
|
class AndroidPermissionsService {
|
||||||
|
/// Construct a service bound to [channel]. Injected for testability;
|
||||||
|
/// production code uses the default channel keyed on
|
||||||
|
/// [androidPermissionsChannelName].
|
||||||
|
AndroidPermissionsService({MethodChannel? channel})
|
||||||
|
: _channel =
|
||||||
|
channel ??
|
||||||
|
(_isAndroid
|
||||||
|
? const MethodChannel(androidPermissionsChannelName)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
/// Platform-detection seam. Web counts as non-Android.
|
||||||
|
static bool get _isAndroid {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isAndroid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The underlying channel, or `null` on non-Android (and unset in
|
||||||
|
/// tests that omit the optional argument).
|
||||||
|
final MethodChannel? _channel;
|
||||||
|
|
||||||
|
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||||
|
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||||
|
// On non-Android, present as granted so the voice-join gate is a
|
||||||
|
// no-op (desktop / iOS have separate audio-permission paths owned
|
||||||
|
// elsewhere; see SDD-101 for iOS).
|
||||||
|
_isAndroid
|
||||||
|
? AndroidRecordAudioPermissionState.unknown
|
||||||
|
: AndroidRecordAudioPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<AndroidPermissionKind, ValueNotifier<AndroidRecordAudioPermissionState>>
|
||||||
|
_extraStates = {
|
||||||
|
for (final kind in AndroidPermissionKind.values)
|
||||||
|
kind: ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||||
|
_isAndroid
|
||||||
|
? AndroidRecordAudioPermissionState.unknown
|
||||||
|
: AndroidRecordAudioPermissionState.granted,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// Latest known [RECORD_AUDIO] state. Defaults to
|
||||||
|
/// [AndroidRecordAudioPermissionState.unknown] on Android and
|
||||||
|
/// [AndroidRecordAudioPermissionState.granted] elsewhere.
|
||||||
|
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||||
|
_state;
|
||||||
|
|
||||||
|
/// Start listening for state updates from Kotlin. Idempotent.
|
||||||
|
///
|
||||||
|
/// On non-Android this is a no-op.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §5.
|
||||||
|
void start() {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
ch.setMethodCallHandler(_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening. Idempotent.
|
||||||
|
void stop() {
|
||||||
|
if (!_started) return;
|
||||||
|
_started = false;
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
ch.setMethodCallHandler(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _handle(MethodCall call) async {
|
||||||
|
if (call.method != methodPermissionStateChanged) return null;
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is! Map) return null;
|
||||||
|
final permission = args['permission'];
|
||||||
|
if (permission is! String) return null;
|
||||||
|
final state = _parseState(args['state'] as String?);
|
||||||
|
if (permission == AndroidPermissionKind.recordAudio.permission) {
|
||||||
|
_state.value = state;
|
||||||
|
}
|
||||||
|
for (final entry in _extraStates.entries) {
|
||||||
|
if (entry.key.permission == permission) {
|
||||||
|
entry.value.value = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueListenable<AndroidRecordAudioPermissionState> permissionState(
|
||||||
|
AndroidPermissionKind kind,
|
||||||
|
) {
|
||||||
|
if (kind == AndroidPermissionKind.recordAudio) {
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
return _extraStates[kind]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request the system permission. Invokes the Kotlin requester and
|
||||||
|
/// then, if the platform synchronously resolves the request, returns
|
||||||
|
/// the resolved state without parking for the listener; otherwise
|
||||||
|
/// awaits the next `permissionStateChanged` emission.
|
||||||
|
///
|
||||||
|
/// On non-Android, resolves immediately with
|
||||||
|
/// [AndroidRecordAudioPermissionState.granted].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-106 §1, §3.
|
||||||
|
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) {
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
}
|
||||||
|
if (_state.value == AndroidRecordAudioPermissionState.granted) {
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
}
|
||||||
|
// Snapshot the pre-invocation state. New Android hosts return the
|
||||||
|
// resolved Kotlin PermissionState string from requestRecordAudio;
|
||||||
|
// older/test hosts may still return null and rely only on the
|
||||||
|
// permissionStateChanged callback below.
|
||||||
|
final preInvokeState = _state.value;
|
||||||
|
String? returnedState;
|
||||||
|
try {
|
||||||
|
returnedState = await ch.invokeMethod<String>(methodRequestRecordAudio);
|
||||||
|
} catch (_) {
|
||||||
|
// Channel-side failure (e.g. missing handler in a debug build).
|
||||||
|
// Fall back to whatever state we currently hold; if still
|
||||||
|
// unknown, surface unknown so the caller can choose its own
|
||||||
|
// policy. Per SRS-209 the voice-join gate treats unknown as
|
||||||
|
// "proceed in listen-only".
|
||||||
|
return _state.value;
|
||||||
|
}
|
||||||
|
final parsedReturnedState = _parseState(returnedState);
|
||||||
|
if (parsedReturnedState != AndroidRecordAudioPermissionState.unknown) {
|
||||||
|
_state.value = parsedReturnedState;
|
||||||
|
return parsedReturnedState;
|
||||||
|
}
|
||||||
|
// M1 fix (corrected): only short-circuit if the platform changed
|
||||||
|
// the state synchronously to a resolved value. A still-unknown
|
||||||
|
// state means we must wait. A still-equal-to-pre-invoke state
|
||||||
|
// means we also must wait (the platform is re-prompting; the
|
||||||
|
// resolution arrives asynchronously after the user interacts with
|
||||||
|
// the dialog).
|
||||||
|
if (_state.value != preInvokeState &&
|
||||||
|
_state.value != AndroidRecordAudioPermissionState.unknown) {
|
||||||
|
return _state.value;
|
||||||
|
}
|
||||||
|
// Otherwise we wait for the listener to observe a state
|
||||||
|
// transition. Bounded wait to avoid hanging the voice-join flow
|
||||||
|
// if the platform dialog is dismissed without a result. The
|
||||||
|
// Kotlin requester treats dismissal as Denied (see
|
||||||
|
// AndroidPermissionRequester.handleRequestPermissionsResult) so
|
||||||
|
// this is defence-in-depth.
|
||||||
|
final completer = Completer<AndroidRecordAudioPermissionState>();
|
||||||
|
void listener() {
|
||||||
|
if (!completer.isCompleted &&
|
||||||
|
_state.value != preInvokeState &&
|
||||||
|
_state.value != AndroidRecordAudioPermissionState.unknown) {
|
||||||
|
completer.complete(_state.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.addListener(listener);
|
||||||
|
try {
|
||||||
|
return await completer.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
onTimeout: () => _state.value,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_state.removeListener(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<AndroidPermissionKind, AndroidRecordAudioPermissionState>>
|
||||||
|
ensureStartupPermissions() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) {
|
||||||
|
return {
|
||||||
|
for (final kind in AndroidPermissionKind.values)
|
||||||
|
kind: AndroidRecordAudioPermissionState.granted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Map<dynamic, dynamic>? returned;
|
||||||
|
try {
|
||||||
|
returned = await ch.invokeMethod<Map<dynamic, dynamic>>(
|
||||||
|
methodRequestStartupPermissions,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return {
|
||||||
|
for (final kind in AndroidPermissionKind.values)
|
||||||
|
kind: permissionState(kind).value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
final resolved = <AndroidPermissionKind, AndroidRecordAudioPermissionState>{};
|
||||||
|
for (final kind in AndroidPermissionKind.values) {
|
||||||
|
final parsed = _parseState(returned?[kind.permission] as String?);
|
||||||
|
if (kind == AndroidPermissionKind.recordAudio) {
|
||||||
|
_state.value = parsed;
|
||||||
|
} else {
|
||||||
|
_extraStates[kind]!.value = parsed;
|
||||||
|
}
|
||||||
|
resolved[kind] = parsed;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep-link to the system app settings page for the permanently
|
||||||
|
/// denied case (SDD-106 §3). On non-Android, a no-op.
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<void>(methodOpenAppSettings);
|
||||||
|
} catch (e, st) {
|
||||||
|
// Best-effort; failure to launch settings is non-fatal. The
|
||||||
|
// Kotlin side already logs ActivityNotFoundException.
|
||||||
|
developer.log(
|
||||||
|
'openAppSettings failed',
|
||||||
|
name: 'AndroidPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the state notifier. Test helper; production keeps the
|
||||||
|
/// service alive for the lifetime of the app.
|
||||||
|
@visibleForTesting
|
||||||
|
void dispose() {
|
||||||
|
stop();
|
||||||
|
_state.dispose();
|
||||||
|
for (final notifier in _extraStates.values) {
|
||||||
|
notifier.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:io' show Directory, File;
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
const String appSemverBaseline = 'v0.1.0';
|
||||||
|
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
|
||||||
|
|
||||||
|
typedef StorageDirectoryProvider = Future<Directory> Function();
|
||||||
|
typedef StorageInitializer = Future<void> Function(String dir);
|
||||||
|
|
||||||
|
Future<void>? _storageInitFuture;
|
||||||
|
Future<void>? _vadBootstrapFuture;
|
||||||
|
StorageDirectoryProvider _storageDirectoryProvider =
|
||||||
|
getApplicationSupportDirectory;
|
||||||
|
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||||
|
|
||||||
|
Future<void> _defaultStorageInitializer(String dir) {
|
||||||
|
return rust.initStorage(dir: dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _copyBundledAssetToDocuments({
|
||||||
|
required String assetPath,
|
||||||
|
required String fileName,
|
||||||
|
}) async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final file = File('${dir.path}/$fileName');
|
||||||
|
final data = await rootBundle.load(assetPath);
|
||||||
|
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||||
|
|
||||||
|
if (await file.exists() && await file.length() == bytes.length) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> configureBundledVadModels() async {
|
||||||
|
final existing = _vadBootstrapFuture;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bootstrap = _configureBundledVadModelsImpl();
|
||||||
|
_vadBootstrapFuture = bootstrap;
|
||||||
|
try {
|
||||||
|
await bootstrap;
|
||||||
|
} catch (_) {
|
||||||
|
_vadBootstrapFuture = null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _configureBundledVadModelsImpl() async {
|
||||||
|
final silero = await _copyBundledAssetToDocuments(
|
||||||
|
assetPath: _sileroVadAsset,
|
||||||
|
fileName: 'silero_vad.onnx',
|
||||||
|
);
|
||||||
|
await rust.setVadModelPath(path: silero.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the human-readable app version displayed in About.
|
||||||
|
///
|
||||||
|
/// The semver baseline is kept in code because iOS strips pre-release
|
||||||
|
/// identifiers from `CFBundleShortVersionString`; `package_info_plus`
|
||||||
|
/// still provides the platform build counter.
|
||||||
|
Future<String> resolveAppVersion({
|
||||||
|
String semverBaseline = appSemverBaseline,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
return appVersionFromBuildNumber(
|
||||||
|
semverBaseline: semverBaseline,
|
||||||
|
buildNumber: info.buildNumber,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return semverBaseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String appVersionFromBuildNumber({
|
||||||
|
required String semverBaseline,
|
||||||
|
required String buildNumber,
|
||||||
|
}) {
|
||||||
|
final build = buildNumber.isEmpty ? '' : '+$buildNumber';
|
||||||
|
return '$semverBaseline$build';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> wireStorage() async {
|
||||||
|
final existing = _storageInitFuture;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initFuture = _wireStorageImpl();
|
||||||
|
_storageInitFuture = initFuture;
|
||||||
|
await initFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _wireStorageImpl() async {
|
||||||
|
var initialized = false;
|
||||||
|
try {
|
||||||
|
final dir = await _storageDirectoryProvider();
|
||||||
|
await _storageInitializer(dir.path);
|
||||||
|
initialized = true;
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort; missing storage just means no identity persistence
|
||||||
|
// and no bookmark list this session.
|
||||||
|
} finally {
|
||||||
|
if (!initialized) {
|
||||||
|
_storageInitFuture = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void debugResetStorageBootstrap({
|
||||||
|
StorageDirectoryProvider? storageDirectoryProvider,
|
||||||
|
StorageInitializer? storageInitializer,
|
||||||
|
}) {
|
||||||
|
_storageInitFuture = null;
|
||||||
|
_vadBootstrapFuture = null;
|
||||||
|
_storageDirectoryProvider =
|
||||||
|
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||||
|
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||||
|
}
|
||||||
|
|
||||||
|
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
||||||
|
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
|
||||||
|
final allNone = results.every((r) => r == ConnectivityResult.none);
|
||||||
|
if (allNone) return rust.BridgeNetworkState.offline;
|
||||||
|
return rust.BridgeNetworkState.online;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> wireConnectivity() async {
|
||||||
|
final connectivity = Connectivity();
|
||||||
|
try {
|
||||||
|
final initial = await connectivity.checkConnectivity();
|
||||||
|
rust.setNetworkState(state: _mapConnectivity(initial));
|
||||||
|
} catch (_) {}
|
||||||
|
connectivity.onConnectivityChanged.listen((results) {
|
||||||
|
rust.setNetworkState(state: _mapConnectivity(results));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
||||||
|
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
|
||||||
|
|
||||||
|
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'Earpiece':
|
||||||
|
return rust.BridgeAudioRoute.earpiece;
|
||||||
|
case 'Speaker':
|
||||||
|
return rust.BridgeAudioRoute.speaker;
|
||||||
|
case 'WiredHeadset':
|
||||||
|
return rust.BridgeAudioRoute.wiredHeadset;
|
||||||
|
case 'BluetoothHfp':
|
||||||
|
return rust.BridgeAudioRoute.bluetoothHfp;
|
||||||
|
case 'BluetoothA2dp':
|
||||||
|
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
||||||
|
default:
|
||||||
|
return rust.BridgeAudioRoute.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void wireAudioLifecycle() {
|
||||||
|
wireIosAudioLifecycle();
|
||||||
|
wireAndroidAudioLifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||||
|
///
|
||||||
|
/// Swift side (AppDelegate) posts route-change and interruption events through
|
||||||
|
/// this channel. The handler dispatches them to the FRB bridge functions on
|
||||||
|
/// the Rust side.
|
||||||
|
void wireIosAudioLifecycle({
|
||||||
|
MethodChannel channel = const MethodChannel(iosAudioLifecycleChannelName),
|
||||||
|
}) {
|
||||||
|
channel.setMethodCallHandler((call) async {
|
||||||
|
try {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'handleRouteChange':
|
||||||
|
final routeStr = call.arguments as String? ?? 'Unknown';
|
||||||
|
final route = parseBridgeAudioRoute(routeStr);
|
||||||
|
rust.handleRouteChange(route: route);
|
||||||
|
break;
|
||||||
|
case 'handleMediaServicesReset':
|
||||||
|
final routeClass = call.arguments as String? ?? 'Unknown';
|
||||||
|
rust.handleMediaServicesResetWithRoute(routeClass: routeClass);
|
||||||
|
break;
|
||||||
|
case 'handleInterruptionBegan':
|
||||||
|
rust.handleInterruptionBegan();
|
||||||
|
break;
|
||||||
|
case 'handleInterruptionEnded':
|
||||||
|
final shouldResume = call.arguments as bool? ?? false;
|
||||||
|
rust.handleInterruptionEnded(shouldResume: shouldResume);
|
||||||
|
break;
|
||||||
|
case 'handleWillResignActive':
|
||||||
|
case 'handleDidEnterBackground':
|
||||||
|
rust.handleInterruptionBegan();
|
||||||
|
break;
|
||||||
|
case 'handleWillEnterForeground':
|
||||||
|
rust.handleInterruptionEnded(shouldResume: true);
|
||||||
|
break;
|
||||||
|
case 'handleWillTerminate':
|
||||||
|
rust.handleInterruptionBegan();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Errors from the Rust side are already logged there; do not propagate
|
||||||
|
// exceptions to the platform framework.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire the Android audio lifecycle MethodChannel.
|
||||||
|
///
|
||||||
|
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change events
|
||||||
|
/// through this channel. This mirrors the iOS dispatch path.
|
||||||
|
void wireAndroidAudioLifecycle({
|
||||||
|
bool isAndroid = false,
|
||||||
|
MethodChannel channel = const MethodChannel(androidAudioLifecycleChannelName),
|
||||||
|
}) {
|
||||||
|
if (!isAndroid && !Platform.isAndroid) return;
|
||||||
|
channel.setMethodCallHandler((call) async {
|
||||||
|
try {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'handleRouteChange':
|
||||||
|
final args = call.arguments;
|
||||||
|
final routeStr = args is Map
|
||||||
|
? (args['routeType'] as String? ?? 'Unknown')
|
||||||
|
: (args as String? ?? 'Unknown');
|
||||||
|
final route = parseBridgeAudioRoute(routeStr);
|
||||||
|
rust.handleRouteChange(route: route);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Errors from the Rust side are already logged there; do not propagate
|
||||||
|
// exceptions to the platform framework.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/// Pure-Dart route-pop policy for SDD-028 (`BackIntentService`).
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 §2 (deterministic route-pop ordering) / SAD-018.
|
||||||
|
///
|
||||||
|
/// This file intentionally has **no Flutter dependency** so it can be
|
||||||
|
/// unit-tested without a `MaterialApp` or `Navigator` context. The
|
||||||
|
/// integration wrapper lives in `back_intent_service.dart`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Decision produced by [decideBackIntent].
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 §2 — exactly one decision per platform back event.
|
||||||
|
sealed class BackIntentDecision {
|
||||||
|
const BackIntentDecision();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PTT is currently transmitting; the back event must be swallowed
|
||||||
|
/// without any UI side effect (SDD-028 §2.a).
|
||||||
|
class IgnoreDueToPtt extends BackIntentDecision {
|
||||||
|
const IgnoreDueToPtt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A modal / dialog route is on top; close it only (SDD-028 §2.b).
|
||||||
|
class CloseModal extends BackIntentDecision {
|
||||||
|
const CloseModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigator can pop a non-root route (SDD-028 §2.c).
|
||||||
|
class PopRoute extends BackIntentDecision {
|
||||||
|
const PopRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At the root route with no modal and no PTT; the platform shall be
|
||||||
|
/// asked to finish the activity (SDD-028 §2.d — `exitCandidate`).
|
||||||
|
class ExitApp extends BackIntentDecision {
|
||||||
|
const ExitApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the back-intent decision deterministically.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 §2. The branch order is intentionally fixed:
|
||||||
|
///
|
||||||
|
/// 1. `pttActive == true` → [IgnoreDueToPtt]
|
||||||
|
/// 2. `modalOpen == true` → [CloseModal]
|
||||||
|
/// 3. `atRoot == false` → [PopRoute]
|
||||||
|
/// 4. otherwise → [ExitApp]
|
||||||
|
///
|
||||||
|
/// This function is pure: identical inputs always produce identical
|
||||||
|
/// outputs and there are no side effects, which is the verification
|
||||||
|
/// surface targeted by SWE4-UV-042.
|
||||||
|
BackIntentDecision decideBackIntent({
|
||||||
|
required bool pttActive,
|
||||||
|
required bool modalOpen,
|
||||||
|
required bool atRoot,
|
||||||
|
}) {
|
||||||
|
if (pttActive) {
|
||||||
|
return const IgnoreDueToPtt();
|
||||||
|
}
|
||||||
|
if (modalOpen) {
|
||||||
|
return const CloseModal();
|
||||||
|
}
|
||||||
|
if (!atRoot) {
|
||||||
|
return const PopRoute();
|
||||||
|
}
|
||||||
|
return const ExitApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/// SDD-028 `BackIntentService` — Dart integration layer.
|
||||||
|
///
|
||||||
|
/// Trace: SDD-028 (Android back-intent normalization) / SAD-018.
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// * Listen on `MethodChannel("app.chanora/back_intent")` for
|
||||||
|
/// `backIntent` calls forwarded by `BackIntentBridge` (Kotlin).
|
||||||
|
/// * Read the current shell state via **injected** getter callbacks
|
||||||
|
/// (`pttActiveProbe`, `modalOpenProbe`, `atRootProbe`) so the class
|
||||||
|
/// is testable without a `MaterialApp` / global Navigator. No
|
||||||
|
/// global statics are introduced.
|
||||||
|
/// * Apply `decideBackIntent(...)` from `back_intent_policy.dart` to
|
||||||
|
/// produce a deterministic `BackIntentDecision`.
|
||||||
|
/// * Dispatch the decision to injected effect callbacks
|
||||||
|
/// (`closeTopModal`, `popRoute`) and, for `ExitApp`, ask the
|
||||||
|
/// platform to finish via `channel.invokeMethod('popToSystem')`.
|
||||||
|
///
|
||||||
|
/// Non-Android platforms: use [BackIntentService.noOp] which never
|
||||||
|
/// registers a channel handler. The rest of the app can construct a
|
||||||
|
/// `BackIntentService` unconditionally.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'back_intent_policy.dart';
|
||||||
|
|
||||||
|
/// Probe returning the current value of a shell-state flag.
|
||||||
|
typedef BackIntentBoolProbe = bool Function();
|
||||||
|
|
||||||
|
/// Effect callback invoked by [BackIntentService] when the policy
|
||||||
|
/// resolves a non-platform decision.
|
||||||
|
typedef BackIntentEffect = void Function();
|
||||||
|
|
||||||
|
/// MethodChannel contract shared with `BackIntentBridge.kt`.
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentChannelName = 'app.chanora/back_intent';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentMethodFromPlatform = 'backIntent';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentMethodPopToSystem = 'popToSystem';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentPayloadKindKey = 'kind';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String backIntentPayloadKindSystemBack = 'system_back';
|
||||||
|
|
||||||
|
/// SDD-028 service. Construct once per app shell.
|
||||||
|
class BackIntentService {
|
||||||
|
BackIntentService({
|
||||||
|
required BackIntentBoolProbe pttActiveProbe,
|
||||||
|
required BackIntentBoolProbe modalOpenProbe,
|
||||||
|
required BackIntentBoolProbe atRootProbe,
|
||||||
|
required BackIntentEffect closeTopModal,
|
||||||
|
required BackIntentEffect popRoute,
|
||||||
|
MethodChannel? channel,
|
||||||
|
}) : _pttActiveProbe = pttActiveProbe,
|
||||||
|
_modalOpenProbe = modalOpenProbe,
|
||||||
|
_atRootProbe = atRootProbe,
|
||||||
|
_closeTopModal = closeTopModal,
|
||||||
|
_popRoute = popRoute,
|
||||||
|
_channel = channel ?? const MethodChannel(backIntentChannelName),
|
||||||
|
_enabled = true;
|
||||||
|
|
||||||
|
/// No-op factory for non-Android targets (SDD-028 §6).
|
||||||
|
///
|
||||||
|
/// Constructs a service whose probes always return false and whose
|
||||||
|
/// effect callbacks do nothing. It never installs a channel handler.
|
||||||
|
factory BackIntentService.noOp() {
|
||||||
|
return BackIntentService._noOp();
|
||||||
|
}
|
||||||
|
|
||||||
|
BackIntentService._noOp()
|
||||||
|
: _pttActiveProbe = _alwaysFalse,
|
||||||
|
_modalOpenProbe = _alwaysFalse,
|
||||||
|
_atRootProbe = _alwaysFalse,
|
||||||
|
_closeTopModal = _noEffect,
|
||||||
|
_popRoute = _noEffect,
|
||||||
|
_channel = const MethodChannel(backIntentChannelName),
|
||||||
|
_enabled = false;
|
||||||
|
|
||||||
|
final BackIntentBoolProbe _pttActiveProbe;
|
||||||
|
final BackIntentBoolProbe _modalOpenProbe;
|
||||||
|
final BackIntentBoolProbe _atRootProbe;
|
||||||
|
final BackIntentEffect _closeTopModal;
|
||||||
|
final BackIntentEffect _popRoute;
|
||||||
|
final MethodChannel _channel;
|
||||||
|
final bool _enabled;
|
||||||
|
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// Install the MethodChannel handler. Safe to call once per instance.
|
||||||
|
/// On the no-op variant this returns without registering anything.
|
||||||
|
void start() {
|
||||||
|
if (!_enabled || _started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_channel.setMethodCallHandler(_handleMethodCall);
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the MethodChannel handler. Safe to call repeatedly.
|
||||||
|
void stop() {
|
||||||
|
if (!_started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_channel.setMethodCallHandler(null);
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and dispatch a back intent. Visible for tests so the
|
||||||
|
/// decision pipeline can be driven without a real platform channel.
|
||||||
|
@visibleForTesting
|
||||||
|
BackIntentDecision dispatch() {
|
||||||
|
final decision = decideBackIntent(
|
||||||
|
pttActive: _pttActiveProbe(),
|
||||||
|
modalOpen: _modalOpenProbe(),
|
||||||
|
atRoot: _atRootProbe(),
|
||||||
|
);
|
||||||
|
_apply(decision);
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||||
|
if (call.method != backIntentMethodFromPlatform) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is Map &&
|
||||||
|
args[backIntentPayloadKindKey] != backIntentPayloadKindSystemBack) {
|
||||||
|
// Unknown kind: ignore defensively per SDD-028 §5 sanitiser stance.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
dispatch();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _apply(BackIntentDecision decision) {
|
||||||
|
switch (decision) {
|
||||||
|
case IgnoreDueToPtt():
|
||||||
|
// SDD-028 §2.a — consume without UI effect.
|
||||||
|
return;
|
||||||
|
case CloseModal():
|
||||||
|
_closeTopModal();
|
||||||
|
return;
|
||||||
|
case PopRoute():
|
||||||
|
_popRoute();
|
||||||
|
return;
|
||||||
|
case ExitApp():
|
||||||
|
// SDD-028 §2.d — defer to platform fallback.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
_channel.invokeMethod<void>(backIntentMethodPopToSystem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _alwaysFalse() => false;
|
||||||
|
static void _noEffect() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
import '../src/rust/lib.dart' as rust_err;
|
||||||
|
|
||||||
|
/// Map a `voiceJoin` error to a localized user-facing message.
|
||||||
|
String channelJoinErrorMessage(AppL10n l10n, Object error) {
|
||||||
|
if (error is rust_err.BridgeError) {
|
||||||
|
return error.when(
|
||||||
|
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||||
|
dnsFailed: (host, reason) =>
|
||||||
|
l10n.channelJoinFailedGeneric('$host: $reason'),
|
||||||
|
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||||
|
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
||||||
|
alreadyConnected: () =>
|
||||||
|
l10n.channelJoinFailedGeneric('already connected'),
|
||||||
|
serverRejected: (code, message) {
|
||||||
|
switch (code) {
|
||||||
|
case 0x0001:
|
||||||
|
return l10n.channelJoinFailedTimeout;
|
||||||
|
case 0x0a08:
|
||||||
|
return l10n.channelJoinFailedPermission;
|
||||||
|
case 0x0302:
|
||||||
|
return l10n.channelJoinAlreadyIn;
|
||||||
|
case 0x030d:
|
||||||
|
return l10n.channelJoinFailedPassword;
|
||||||
|
case 0x0309:
|
||||||
|
return l10n.channelJoinFailedFull;
|
||||||
|
case 0x030a:
|
||||||
|
return l10n.channelJoinFailedFamilyFull;
|
||||||
|
case 0x030e:
|
||||||
|
return l10n.channelJoinFailedPrivate;
|
||||||
|
default:
|
||||||
|
return l10n.channelJoinFailedGeneric(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return l10n.channelJoinFailedGeneric(error.toString());
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/// Alignment requested by a TeamSpeak spacer channel tag.
|
||||||
|
enum SpacerAlignment {
|
||||||
|
/// Left-aligned spacer content.
|
||||||
|
left,
|
||||||
|
|
||||||
|
/// Right-aligned spacer content.
|
||||||
|
right,
|
||||||
|
|
||||||
|
/// Center-aligned spacer content.
|
||||||
|
center,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in TeamSpeak spacer separator patterns.
|
||||||
|
enum SpacerSpecialType {
|
||||||
|
/// `___`
|
||||||
|
solidLine,
|
||||||
|
|
||||||
|
/// `---`
|
||||||
|
dashLine,
|
||||||
|
|
||||||
|
/// `...`
|
||||||
|
dotLine,
|
||||||
|
|
||||||
|
/// `-.-`
|
||||||
|
dashDotLine,
|
||||||
|
|
||||||
|
/// `-..`
|
||||||
|
dashDotDotLine,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed form of a TeamSpeak spacer channel name.
|
||||||
|
class SpacerChannelNameParseResult {
|
||||||
|
/// Construct a spacer parse result.
|
||||||
|
const SpacerChannelNameParseResult({
|
||||||
|
required this.isSpacer,
|
||||||
|
required this.isValid,
|
||||||
|
required this.alignment,
|
||||||
|
required this.isRepeating,
|
||||||
|
required this.uniqueSuffix,
|
||||||
|
required this.text,
|
||||||
|
required this.specialType,
|
||||||
|
required this.isBlankSpacer,
|
||||||
|
this.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// True only when the name begins with a valid bracketed spacer tag.
|
||||||
|
final bool isSpacer;
|
||||||
|
|
||||||
|
/// True when the bracketed spacer tag follows the supported syntax.
|
||||||
|
final bool isValid;
|
||||||
|
|
||||||
|
/// Optional alignment flag. Null means the server/client default.
|
||||||
|
final SpacerAlignment? alignment;
|
||||||
|
|
||||||
|
/// True when `*` appears in the tag and the text should repeat.
|
||||||
|
final bool isRepeating;
|
||||||
|
|
||||||
|
/// The exact suffix after `Spacer` and before `]`.
|
||||||
|
final String uniqueSuffix;
|
||||||
|
|
||||||
|
/// The exact text after the closing `]`.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
/// Built-in separator type for special text values.
|
||||||
|
final SpacerSpecialType? specialType;
|
||||||
|
|
||||||
|
/// True for the known blank-looking right-aligned dot spacer.
|
||||||
|
final bool isBlankSpacer;
|
||||||
|
|
||||||
|
/// Parse error/reason for non-spacer or malformed names.
|
||||||
|
final String? reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for formatting a TeamSpeak spacer channel name.
|
||||||
|
class SpacerChannelNameFormatOptions {
|
||||||
|
/// Construct spacer formatting options.
|
||||||
|
const SpacerChannelNameFormatOptions({
|
||||||
|
this.alignment,
|
||||||
|
this.isRepeating = false,
|
||||||
|
this.uniqueSuffix = '',
|
||||||
|
this.text = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Optional alignment flag. Null means omit the alignment prefix.
|
||||||
|
final SpacerAlignment? alignment;
|
||||||
|
|
||||||
|
/// Whether to include the repeating `*` tag flag.
|
||||||
|
final bool isRepeating;
|
||||||
|
|
||||||
|
/// Uniqueness suffix to place after `Spacer`.
|
||||||
|
final String uniqueSuffix;
|
||||||
|
|
||||||
|
/// Text to place after the closing `]`.
|
||||||
|
final String text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _notSpacer = SpacerChannelNameParseResult(
|
||||||
|
isSpacer: false,
|
||||||
|
isValid: false,
|
||||||
|
alignment: null,
|
||||||
|
isRepeating: false,
|
||||||
|
uniqueSuffix: '',
|
||||||
|
text: '',
|
||||||
|
specialType: null,
|
||||||
|
isBlankSpacer: false,
|
||||||
|
reason: 'not a spacer channel name',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Return true when [name] begins with a valid bracketed spacer tag.
|
||||||
|
bool isSpacerChannelName(String name) => parseSpacerChannelName(name).isSpacer;
|
||||||
|
|
||||||
|
/// Parse a TeamSpeak spacer channel name.
|
||||||
|
///
|
||||||
|
/// Supported tag form is `[?Spacer#]Text`, parsed case-insensitively.
|
||||||
|
/// `?` may be `l`, `r`, or `c`; `*` may also appear in the tag to
|
||||||
|
/// mark repeating spacer content. The suffix and text are preserved
|
||||||
|
/// exactly as written.
|
||||||
|
SpacerChannelNameParseResult parseSpacerChannelName(String name) {
|
||||||
|
if (!name.startsWith('[')) return _notSpacer;
|
||||||
|
|
||||||
|
final close = name.indexOf(']');
|
||||||
|
if (close < 0) {
|
||||||
|
return _invalidSpacerName('missing closing bracket');
|
||||||
|
}
|
||||||
|
|
||||||
|
final tag = name.substring(1, close);
|
||||||
|
final text = name.substring(close + 1);
|
||||||
|
final match = RegExp(
|
||||||
|
r'^([lrc*]*)(spacer)(.*)$',
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(tag);
|
||||||
|
|
||||||
|
if (match == null) {
|
||||||
|
if (tag.toLowerCase().contains('spacer')) {
|
||||||
|
return _invalidSpacerName('invalid spacer tag');
|
||||||
|
}
|
||||||
|
return _notSpacer;
|
||||||
|
}
|
||||||
|
|
||||||
|
final flags = match.group(1) ?? '';
|
||||||
|
final uniqueSuffix = match.group(3) ?? '';
|
||||||
|
final alignmentFlags = flags
|
||||||
|
.toLowerCase()
|
||||||
|
.split('')
|
||||||
|
.where((flag) => flag == 'l' || flag == 'r' || flag == 'c')
|
||||||
|
.toList();
|
||||||
|
final repeatingFlags = flags.split('').where((flag) => flag == '*').length;
|
||||||
|
|
||||||
|
if (alignmentFlags.length > 1) {
|
||||||
|
return _invalidSpacerName('multiple alignment flags');
|
||||||
|
}
|
||||||
|
if (repeatingFlags > 1) {
|
||||||
|
return _invalidSpacerName('multiple repeating flags');
|
||||||
|
}
|
||||||
|
|
||||||
|
final alignmentFlag = alignmentFlags.isEmpty ? null : alignmentFlags.first;
|
||||||
|
final alignment = switch (alignmentFlag) {
|
||||||
|
'l' => SpacerAlignment.left,
|
||||||
|
'r' => SpacerAlignment.right,
|
||||||
|
'c' => SpacerAlignment.center,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
final specialType = _specialTypeForText(text);
|
||||||
|
return SpacerChannelNameParseResult(
|
||||||
|
isSpacer: true,
|
||||||
|
isValid: true,
|
||||||
|
alignment: alignment,
|
||||||
|
isRepeating: repeatingFlags == 1,
|
||||||
|
uniqueSuffix: uniqueSuffix,
|
||||||
|
text: text,
|
||||||
|
specialType: specialType,
|
||||||
|
isBlankSpacer: alignment == SpacerAlignment.right && text == '.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a TeamSpeak spacer channel name deterministically.
|
||||||
|
///
|
||||||
|
/// The output uses canonical `Spacer` casing and places `*` before
|
||||||
|
/// the alignment flag when both are present.
|
||||||
|
String formatSpacerChannelName(SpacerChannelNameFormatOptions options) {
|
||||||
|
final repeatFlag = options.isRepeating ? '*' : '';
|
||||||
|
final alignFlag = switch (options.alignment) {
|
||||||
|
SpacerAlignment.left => 'l',
|
||||||
|
SpacerAlignment.right => 'r',
|
||||||
|
SpacerAlignment.center => 'c',
|
||||||
|
null => '',
|
||||||
|
};
|
||||||
|
return '[$repeatFlag${alignFlag}Spacer${options.uniqueSuffix}]${options.text}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a channel name into display text while preserving the
|
||||||
|
/// underlying channel entity and behavior.
|
||||||
|
String channelSpacerLabel(String raw, {int repeatColumns = 32}) {
|
||||||
|
final parsed = parseSpacerChannelName(raw);
|
||||||
|
if (!parsed.isValid) return raw;
|
||||||
|
if (parsed.isBlankSpacer) return '';
|
||||||
|
if (parsed.isRepeating) {
|
||||||
|
return _repeatSpacerText(parsed.text, repeatColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (parsed.specialType) {
|
||||||
|
SpacerSpecialType.solidLine => '────────',
|
||||||
|
SpacerSpecialType.dashLine => '╌╌╌╌╌╌╌╌',
|
||||||
|
SpacerSpecialType.dotLine => '········',
|
||||||
|
SpacerSpecialType.dashDotLine => '─╶─╶─╶─╶─╶─╶─╶─╶',
|
||||||
|
SpacerSpecialType.dashDotDotLine => '─╶╶─╶╶─╶╶─╶╶',
|
||||||
|
null => parsed.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SpacerChannelNameParseResult _invalidSpacerName(String reason) {
|
||||||
|
return SpacerChannelNameParseResult(
|
||||||
|
isSpacer: false,
|
||||||
|
isValid: false,
|
||||||
|
alignment: null,
|
||||||
|
isRepeating: false,
|
||||||
|
uniqueSuffix: '',
|
||||||
|
text: '',
|
||||||
|
specialType: null,
|
||||||
|
isBlankSpacer: false,
|
||||||
|
reason: reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpacerSpecialType? _specialTypeForText(String text) {
|
||||||
|
return switch (text) {
|
||||||
|
'___' => SpacerSpecialType.solidLine,
|
||||||
|
'---' => SpacerSpecialType.dashLine,
|
||||||
|
'...' => SpacerSpecialType.dotLine,
|
||||||
|
'-.-' => SpacerSpecialType.dashDotLine,
|
||||||
|
'-..' => SpacerSpecialType.dashDotDotLine,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _repeatSpacerText(String pattern, int repeatColumns) {
|
||||||
|
if (pattern.isEmpty) return '────────';
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
while (buffer.length < repeatColumns) {
|
||||||
|
buffer.write(pattern);
|
||||||
|
}
|
||||||
|
return buffer.toString().substring(0, repeatColumns);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../design/chanora_tokens.dart';
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
|
||||||
|
enum ConnectionPhase {
|
||||||
|
/// App is disconnected, no error.
|
||||||
|
idle,
|
||||||
|
|
||||||
|
/// Connecting to server.
|
||||||
|
connecting,
|
||||||
|
|
||||||
|
/// Connected but still synchronizing snapshot.
|
||||||
|
synchronizing,
|
||||||
|
|
||||||
|
/// Connected and ready.
|
||||||
|
connected,
|
||||||
|
|
||||||
|
/// Connection lost, auto-reconnecting.
|
||||||
|
reconnecting,
|
||||||
|
|
||||||
|
/// Explicitly disconnected by user.
|
||||||
|
disconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConnectionPhaseState on ConnectionPhase {
|
||||||
|
bool get isServerReachable =>
|
||||||
|
this == ConnectionPhase.connected ||
|
||||||
|
this == ConnectionPhase.synchronizing ||
|
||||||
|
this == ConnectionPhase.reconnecting;
|
||||||
|
|
||||||
|
bool get canOpenChat =>
|
||||||
|
this == ConnectionPhase.connected ||
|
||||||
|
this == ConnectionPhase.synchronizing;
|
||||||
|
|
||||||
|
bool get canDisconnect => canOpenChat;
|
||||||
|
|
||||||
|
bool shouldShowSnapshotLoading({required bool hasSnapshot}) =>
|
||||||
|
isServerReachable && !hasSnapshot;
|
||||||
|
|
||||||
|
bool canOpenChatWithSnapshot({required bool hasSnapshot}) =>
|
||||||
|
canOpenChat && hasSnapshot;
|
||||||
|
|
||||||
|
ConnectionTokens tokens(ColorScheme colorScheme) {
|
||||||
|
switch (this) {
|
||||||
|
case ConnectionPhase.idle:
|
||||||
|
case ConnectionPhase.disconnected:
|
||||||
|
return ConnectionTokens.disconnected(colorScheme);
|
||||||
|
case ConnectionPhase.connecting:
|
||||||
|
return ConnectionTokens.connecting(colorScheme);
|
||||||
|
case ConnectionPhase.synchronizing:
|
||||||
|
return ConnectionTokens.synchronizing(colorScheme);
|
||||||
|
case ConnectionPhase.connected:
|
||||||
|
return ConnectionTokens.connected(colorScheme);
|
||||||
|
case ConnectionPhase.reconnecting:
|
||||||
|
return ConnectionTokens.reconnecting(colorScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionPhase phaseAfterConnectedEvent(
|
||||||
|
ConnectionPhase phase, {
|
||||||
|
required bool hasSnapshot,
|
||||||
|
}) {
|
||||||
|
return phase == ConnectionPhase.connected && hasSnapshot
|
||||||
|
? ConnectionPhase.connected
|
||||||
|
: ConnectionPhase.synchronizing;
|
||||||
|
}
|
||||||
|
|
||||||
|
String connectionStatusText({
|
||||||
|
required ConnectionPhase phase,
|
||||||
|
required AppL10n l10n,
|
||||||
|
String? serverName,
|
||||||
|
String? lostReason,
|
||||||
|
int? reconnectAttempt,
|
||||||
|
int? reconnectDelay,
|
||||||
|
}) {
|
||||||
|
switch (phase) {
|
||||||
|
case ConnectionPhase.idle:
|
||||||
|
return l10n.statusIdle;
|
||||||
|
case ConnectionPhase.connecting:
|
||||||
|
return l10n.statusConnecting;
|
||||||
|
case ConnectionPhase.synchronizing:
|
||||||
|
return 'Synchronizing...';
|
||||||
|
case ConnectionPhase.connected:
|
||||||
|
return l10n.statusConnected(serverName ?? '');
|
||||||
|
case ConnectionPhase.reconnecting:
|
||||||
|
return reconnectAttempt != null
|
||||||
|
? l10n.statusReconnecting(reconnectAttempt, reconnectDelay ?? 0)
|
||||||
|
: l10n.statusConnectionLost(lostReason ?? '');
|
||||||
|
case ConnectionPhase.disconnected:
|
||||||
|
return l10n.statusIdle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionPhase phaseAfterSnapshotApplied(ConnectionPhase phase) {
|
||||||
|
return phase == ConnectionPhase.synchronizing
|
||||||
|
? ConnectionPhase.connected
|
||||||
|
: phase;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/// iOS microphone permission integration for AVAudioSession.
|
||||||
|
///
|
||||||
|
/// Trace:
|
||||||
|
/// - SRS-164 (iOS system permission presentation and settings path).
|
||||||
|
/// - SRS-114 / SRS-138 (iOS platform-service behaviour for audio).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'android_permissions_service.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String iosPlatformChannelName = 'chanora/ios_platform';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodGetMicrophonePermissionState =
|
||||||
|
'getMicrophonePermissionState';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestMicrophonePermission = 'requestMicrophonePermission';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodIosOpenAppSettings = 'openAppSettings';
|
||||||
|
|
||||||
|
/// Dart-side integration for iOS microphone permission state.
|
||||||
|
class IosPermissionsService {
|
||||||
|
IosPermissionsService({MethodChannel? channel})
|
||||||
|
: _channel =
|
||||||
|
channel ??
|
||||||
|
(_isIOS ? const MethodChannel(iosPlatformChannelName) : null);
|
||||||
|
|
||||||
|
static bool get _isIOS {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isIOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
final MethodChannel? _channel;
|
||||||
|
|
||||||
|
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||||
|
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||||
|
_isIOS
|
||||||
|
? AndroidRecordAudioPermissionState.unknown
|
||||||
|
: AndroidRecordAudioPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||||
|
_state;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
_state.value = _parseState(
|
||||||
|
await ch.invokeMethod<String>(methodGetMicrophonePermissionState),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
_state.value = AndroidRecordAudioPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {}
|
||||||
|
|
||||||
|
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return AndroidRecordAudioPermissionState.granted;
|
||||||
|
if (_state.value == AndroidRecordAudioPermissionState.granted) {
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final state = _parseState(
|
||||||
|
await ch.invokeMethod<String>(methodRequestMicrophonePermission),
|
||||||
|
);
|
||||||
|
_state.value = state;
|
||||||
|
return state;
|
||||||
|
} catch (_) {
|
||||||
|
return _state.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<bool>(methodIosOpenAppSettings);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'openAppSettings failed',
|
||||||
|
name: 'IosPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void dispose() {
|
||||||
|
_state.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Granted':
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
case 'Denied':
|
||||||
|
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||||
|
case 'NotDetermined':
|
||||||
|
return AndroidRecordAudioPermissionState.denied;
|
||||||
|
default:
|
||||||
|
return AndroidRecordAudioPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||