Closes DEC-032. Restores the canonical Android ABI set
{arm64-v8a, armeabi-v7a, x86_64} per SDD-073 item 4 / SDD-118 item 3.
Root cause was the audiopus_sys + cmake-rs + NDK toolchain-file gap:
cargo-ndk 4.x sets ANDROID_ABI / ANDROID_PLATFORM as env vars per
invocation, but upstream cmake-rs 0.x does not forward them to the
child cmake invocation as -D variables, so armeabi-v7a and x86_64
configure steps fell through to the toolchain-file default and
failed to build.
Fix:
- Cargo.toml: add a workspace [patch.crates-io] stanza pinning the
cmake crate to fork pr2502/cmake-rs @ commit
bdad5edc569d82151922c5c6c4685b1563f12aa1 (branch android-build),
which carries cmake-rs PR #257
(https://github.com/rust-lang/cmake-rs/pull/257). The patch is a
9-line addition that forwards ANDROID_ABI and ANDROID_PLATFORM
from the env to the child cmake as -D variables.
- Cargo.lock: regenerated by 'cargo update -p cmake'; the lone
cmake entry now points at the fork rev.
- apps/chanora_flutter/android/app/build.gradle.kts: restore
abiFilters to {arm64-v8a, armeabi-v7a, x86_64}; remove the
TODO(x86_64/armv7 follow-up) comment.
- docs/governance/product-decision-register.md: mark DEC-032 as
Resolved (2026-05-18) with the resolution mechanism, update the
§3 / §7 rows, and append a 0.9.8.1 change-history entry.
Verification (host: Linux):
cargo update -p cmake -> pulled fork rev
cargo check --workspace --all-targets -> PASS
cargo test --workspace -> PASS (no regressions)
cargo ndk --platform 28 -t arm64-v8a build -p chanora_bridge -> PASS
cargo ndk --platform 28 -t armeabi-v7a build -p chanora_bridge -> PASS
cargo ndk --platform 28 -t x86_64 build -p chanora_bridge -> PASS
Upstream tracking: re-evaluate the [patch.crates-io] override once
cmake-rs PR #257 merges and a fresh cmake release lands on
crates.io; at that point switch to a plain dep bump and remove the
override.
Author SysRS-310 ratifying the macOS minimum runtime baseline at 10.15
(Catalina) at the SysRS layer, parallel to SysRS-286 (iOS 13.0) and
SysRS-288 (Android API 28).
Closes the Wave 1.5 traceability-audit deferred-but-optional follow-up.
ID allocation: SysRS-310 (not SysRS-290, which is already allocated to
MVP single-active-server-connection scope); monotonic numbering
preserved.
Cross-references SAD-087, SDD-119, SysRS-286, SysRS-288. Verification:
Review + Platform Test on a macOS 10.15 system. Raising the baseline
(e.g., to 11.0 / Big Sur) requires a DEC entry.
Trace: SysRS v0.9.11, SAD-087, SDD-119.
Resolve the macOS half of the SDD-119 item 3 single-source-of-truth follow-up. The chanora_bridge cdylib macOS deployment-target floor ('10.15') was previously hard-coded at 7 sites across Podfile and chanora_bridge.podspec; this commit collapses them to a single Ruby constant declaration in a new SoT file.
Selected Option B (Ruby constant) over Option A (.xcconfig — rejected because the podspec prepare_command runs before any xcconfig is applied) and Option C (versioned text file — rejected as overkill given both consumers are already Ruby). Realizes SAD-087(a)'s 'single macOS build-configuration location' mandate.
Out of scope: the pbxproj 'MACOSX_DEPLOYMENT_TARGET = 10.15' lines (565/666/717) belong to the PBXProject default config and are independently overridden to '11.0' at the Runner PBXNativeTarget level; they are the Runner app's floor, not the bridge cdylib's floor. The iOS half (IPHONEOS_DEPLOYMENT_TARGET=13.0) remains an open follow-up.
Files: new apps/chanora_flutter/macos/macos_deployment_target.rb (MACOS_BRIDGE_DEPLOYMENT_TARGET = '10.15'.freeze); Podfile + chanora_bridge.podspec require_relative the constant and consume it at 7 sites; docs/architecture/sdd.md SDD-119 item 3 rewritten + Notes bullet updated + v0.9.17 changelog entry.
Canonical baseline lives at crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json
and is updated only via the bench-baseline-update.yml workflow.
SDD-120 amendment v0.9.15 → v0.9.16 reflecting commit 3a7750a discovery.
The two post-processor tools (emit_baseline, compare_baseline) were originally
specified in SDD-120 §2 as residing in crates/chanora_audio/benches/, with
[[bin]] declarations in Cargo.toml. The implementation discovered that Cargo's
dependency resolver only routes [dev-dependencies] to [[test]], [[bench]], and
[[example]] targets — NOT to [[bin]] targets under src/bin/ or to ad-hoc paths.
Routing serde_json (required by both binaries) as a regular [dependencies] entry
to support [[bin]] placement would force it into the production cdylib build,
contradicting SDD-120 §10 release-artifact isolation: "no production telemetry
export" and "no benchmark surface in shipping artifacts." Confirmed by the user's
question about whether benchmark crates ship in the release artifact (they must
not).
The correct Cargo-idiomatic placement is examples/. Cargo auto-discovers files
under examples/ as example targets; they receive [dev-dependencies] routing; and
they are excluded from cargo build --release and from flutter build --release
artifacts by Cargo design.
Amendment scope (SDD-120 only):
- §1 item 4: [[bin]] → [[example]]; cargo run --bin → cargo run --example.
- §2: added item 5 with the rationale clause + Cargo dependency resolver
explanation.
- §5 item 1: path benches/ → examples/; invocation flag --bin → --example.
- §5 item 4: dev-dependencies routing clause updated.
- §6 step 6/8: --bin → --example.
- §7 step 5: --bin → --example.
- §10 item 1: release-artifact-isolation bullet extended to cite the
[dev-dependencies] Cargo design mechanism that enforces it.
- §11 verification matrix unchanged.
- "Allocated to" line + "Software units" list updated to reference examples/
paths.
No semantic change to SDD-120: same harness, same metrics, same workflows, same
out-of-scope deferrals. The implementation at 3a7750a already lives at the
corrected paths; this amendment brings the SDD text into agreement with the
code.
Implementation of SDD-120 §1-§8:
Bench harness (crates/chanora_audio/benches/):
- common.rs: deterministic synthetic audio (440 Hz sine, no RNG).
- realtime_capture.rs: bench_capture_alloc_count (dhat) +
bench_capture_callback_wall_clock (criterion).
- opus_codec.rs: bench_opus_encode_latency + bench_opus_decode_latency
(direct audiopus, not AudioHandler — SDD-120 §3 item 4).
- resampler.rs: bench_resampler_throughput across 44.1->48 /
16->48 / 48->48 passthrough.
CI tooling (crates/chanora_audio/examples/):
- emit_baseline.rs: aggregates criterion estimates.json outputs
into the SRS-217 baseline schema.
- compare_baseline.rs: applies SRS-219 tolerance, renders markdown
table with 🟢/🟡/🔴 markers + yellow simpler-form realization per
SDD-120 §8.
Deviation from SDD-120 §2 / §5 / §7 placement: these tools live
under examples/, not benches/ or src/bin/. Rationale: they must
consume serde_json (a dev-only dep — production builds must not
pull it). Cargo only resolves dev-dependencies for [[test]],
[[bench]], and [[example]] targets; [[bin]] targets under
src/bin/ see only regular [dependencies]. examples/ keeps the
binaries out of the production dep tree while still giving them
cargo run --example invocation. An SDD-120 amendment should
reflect this.
Workflows (.github/workflows/):
- bench-advisory.yml: PR + push triggers; runs benches; posts a
sticky PR comment via actions/github-script@v7; job status is
always success (SRS-218 clause 4 — non-blocking).
- bench-baseline-update.yml: workflow_dispatch only; runs benches;
opens PR via peter-evans/create-pull-request@v6 (sole writer of
the SAD-089 baseline JSON).
Cargo.toml additions ([dev-dependencies] only — verified excluded
from --release builds): criterion 0.5, dhat 0.3, serde_json 1.
Source-code seam: minimal pub-but-#[doc(hidden)] bench_seam module
in chanora_audio (engine.rs + lib.rs re-export) so the criterion
bench harness can construct a CaptureState and drive
CaptureState::ingest without re-implementing the engine (SDD-120
§3). Non-iOS targets only — CaptureState itself is iOS-gated.
Initial baseline seed: crates/chanora_audio/benches/baselines/
x86_64-unknown-linux-gnu.json = {}. compare_baseline handles the
missing-baseline case gracefully and emits a 'no red markers'
report; the first manual dispatch of bench-baseline-update.yml
after merge establishes the real values.
Out of scope per SDD-120 §10: production telemetry export,
build-failing hard CI gate, multi-host benchmarking, IDE
integration, Dart-side bridge round-trip bench.
Verification:
- cargo check --workspace --all-targets: PASS.
- cargo bench --bench realtime_capture --no-run: PASS.
- cargo bench --bench opus_codec --no-run: PASS.
- cargo bench --bench resampler --no-run: PASS.
- cargo build --example emit_baseline --example compare_baseline
-p chanora_audio: PASS.
- cargo test --workspace: 106 passed, 0 failed, 3 ignored — no
regression from prior count.
The capture cpal callback (CaptureState::ingest) ran two heap
allocations per callback on the realtime audio thread:
1. engine.rs:1196-1202 — fresh `mono: Vec<f32>` for the downmix
output, once per cpal callback (50–100 Hz).
2. engine.rs:1217-1218 — `pcm_accum.drain(..FRAME_SAMPLES).collect()`
building a fresh Vec<f32> of 960 samples per Opus frame.
Both sites mirror the pattern already fixed for the output side at
engine.rs:1389-1397, where allocating per callback on glibc malloc
was correlated with user-perceptible audio popping. The output-side
fix replaced the per-callback allocation with a pre-allocated
`scratch` Vec that is cleared and resized in place; this commit
applies the same template to the capture side.
Changes:
- Add `mono_scratch: Vec<f32>` and `frame_scratch: Vec<f32>` to
CaptureState. Initialised with Vec::with_capacity(4096) and
Vec::with_capacity(FRAME_SAMPLES=960) respectively in
CaptureState::new.
- Replace the downmix Vec construction with in-place push into
`self.mono_scratch`; `clear()` retains capacity across callbacks.
- Replace the drain().collect() with `self.frame_scratch.extend(
self.pcm_accum.drain(..FRAME_SAMPLES))`; same capacity-retention.
- The resampler call uses std::mem::take to swap the scratch buffer
out for the duration of the &mut self call, then moves it back —
the backing allocation is preserved across callbacks.
Algorithm semantics are unchanged: same downmix arithmetic, same
clamp loop, same Opus encode call sequence. Only the storage
strategy differs.
Out of scope (intentionally not touched):
- Android audio path (android_voice_unit.rs, mobile_voice_backend.rs):
researcher constraint C-4 — the Android cpal data path is mid-
migration and being replaced.
- Output callback at engine.rs:1409+: the only obvious per-callback
allocation there (`scratch`) was already fixed; a fuller audit
is a separate scope decision.
- The `scratch` buffer at engine.rs:1389-1397 — already correct.
Verification:
- cargo check --workspace --all-targets: passes.
- cargo test --workspace: 106 passed / 0 failed / 3 ignored.
- cargo clippy --workspace --all-targets: no new lints introduced;
the one warning inside the edited region (clamp-like pattern at
line 1266) was pre-existing on the copied clamp loop.
Dart consumer for the Android permission state pipeline. New
AndroidPermissionsService listens on the app.chanora/android_permissions
MethodChannel and exposes a ValueListenable for the UI. The voice-join
flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and
clamps to listen-only via setHardMute on denial. A non-modal banner
above the VoiceBar surfaces the Grant / Open Settings action depending
on whether the state is Denied or PermanentlyDenied. On non-Android
hosts the service short-circuits to granted; the banner is never built.
Also adds the BackIntentService Dart consumer (back_intent_policy +
back_intent_service) which the Kotlin BackIntentBridge invokes via
MethodChannel for deterministic route-pop ordering.
Trace: SDD-028, SDD-106, SRS-163, SRS-209.
Android P0 platform shell:
- ChanoraApplication: early System.loadLibrary("c++_shared") +
System.loadLibrary("chanora_bridge") so JNI is hot before
MainActivity.onCreate.
- MainActivity: configureFlutterEngine + onResume/onDestroy wiring
for BackIntentBridge and AndroidPermissionRequester; publishes
permission-state changes through both the MethodChannel (Dart UI)
and the JNI hook (Rust audio engine).
- AndroidVoiceForegroundService: microphone-type foreground service
with notification channel chanora.voice.session per SDD-107.
- AndroidPermissionRequester: RECORD_AUDIO state machine with
persisted "has-ever-requested" flag so PermanentlyDenied is
correctly distinguished from never-asked across cold launches.
- BackIntentBridge: API 33+ OnBackInvokedCallback + pre-33
OnBackPressedDispatcher with deterministic Dart-side policy.
- MethodChannels: centralized constants for app.chanora/*.
- build.gradle.kts: SDD-118 Gradle automation that auto-builds the
Rust cdylib via cargo-ndk with per-ABI Exec tasks, minimal-env
isolation, CMAKE_TOOLCHAIN_FILE pinning, libc++_shared.so staging,
release-inspection assertion. abiFilters temporarily reduced to
arm64-v8a only per DEC-032 (multi-ABI restoration pending).
- AndroidManifest.xml: INTERNET, RECORD_AUDIO, FOREGROUND_SERVICE,
FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS,
MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT permissions; service
declaration with foregroundServiceType=microphone.
- proguard-rules.pro: keep rules for JNI native methods + Flutter
plugin entry points + FRB bindings.
Trace: SDD-073, SDD-105, SDD-106, SDD-107, SDD-108, SDD-110, SDD-118,
SRS-111, SRS-119, SRS-163, SRS-187, SRS-209, SRS-215.
Per SDD-106 §5 add BridgeEvent::PermissionState{permission, state}
with the PermissionStateKind enum (Granted, Denied, PermanentlyDenied,
Unknown). The Kotlin side publishes mid-session permission changes
through a new JNI entry point Java_app_chanora_chanora_1flutter
_MainActivity_publishPermissionState routed by the new
permission_jni.rs module; the Rust audio engine subscribes and
authoritatively clamps the transmit gate (see SDD-106 §6).
Adds crates/chanora_bridge/build.rs to emit
cargo:rustc-link-lib=dylib=c++_shared on Android so libchanora_bridge
.so carries DT_NEEDED libc++_shared.so; this is required by Android
API 24+ per-library linker namespaces to resolve __cxa_pure_virtual
and friends at System.loadLibrary time.
Includes the FRB-regenerated Dart counterparts so each commit is
independently buildable.
Trace: SDD-105, SDD-106 §5, SDD-118 item 6 (extended).
Per SDD-106 §6 add a permission-state clamp to TransmitModeSelector.
When RECORD_AUDIO is Denied or PermanentlyDenied the transmit gate
is forced false regardless of PTT or voice-activity state; on
Granted the clamp releases and normal transmit decisions resume.
The clamp takes precedence over PTT and hard_mute in the decision
ordering documented inline.
Three new tests cover the clamp behavior, the release-on-grant
transition, and the non-RECORD_AUDIO ignore path.
Trace: SDD-106 §6, SRS-209.
Replace the prior one-shot android_engage_voice_communication call
with a ModeStack-mediated acquire/release pair. AudioEngine snapshots
the system audio mode on first acquire via android_get_audio_mode()
and restores it on last release via android_set_audio_mode(prior).
MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime
per SDD-108.
Includes the Android AudioManager getMode/setMode JNI helpers
(placed in chanora_audio::engine alongside the existing JNI surface)
and the small ptt.rs touch needed for the SDD-108 ID-tag on the
existing tests.
Trace: SDD-108, SDD-115.
Introduce ModeStack, a pure-Rust refcount-composable wrapper for
Android audio-mode acquire/release with prior-mode snapshot. Per
SDD-108 §1/§2 the engine snapshots the system audio mode on first
acquire and restores it on last release; composed acquires are
no-ops while the mode is held.
ModeStack is panic-free; release-on-zero returns AlreadyReleased
rather than panicking. Six SWE4-UV-045-tagged unit tests cover the
acquire/release semantics on the host target.
Trace: SDD-108, SWE4-UV-045.
Debug and Profile had ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO which
blocked outbound TCP. All three configs now have:
- ENABLE_HARDENED_RUNTIME = YES
- ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES
This was the root cause of the 'Operation not permitted' connection
failure on macOS Sequoia.
macOS:
- Transparent title bar with hidden title, full-size content view
- macOS: inline Row header (no AppBar) with 56px traffic-light pad
- Other platforms: standard Material AppBar unchanged
- App name 'Chanora' in CFBundleName/CFBundleDisplayName (iOS + macOS)
- NSLocalNetworkUsageDescription added to both platforms
Linux:
- tools/build-linux.sh: builds Rust .so + Flutter bundle + tarball
- Verifies GTK3, libopus dev headers, Rust target
- Copies libchanora_bridge.so into bundle/lib/
Required for local network privacy prompt on macOS 15+ and iOS 14+.
App appears in System Settings → Local Network after connecting to a
LAN server. Internet-hosted servers only need network.client entitlement.
When macOS denies network access (PermissionDenied), show a localized
dialog explaining how to grant permission in System Settings, with
an 'Open System Settings' button that opens directly to the Local
Network privacy pane.
Also adds author info (Edison Jwa) to About dialog and moves
diagnostics button from AppBar into About dialog.