refactor(android): split C++ into wzp-native cdylib, loaded at runtime
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 3m34s

Phase 1 of the big refactor. Escape the Tauri Android
__init_tcb+4 symbol leak (rust-lang/rust#104707) by making
wzp-desktop's Android .so pure Rust — ZERO cc::Build, no cpp/ files,
no C++ in the rustc link step. All future C++ (Oboe audio bridge)
lives in a new standalone cdylib crate `wzp-native` which is built
with cargo-ndk (the same path the legacy wzp-android crate uses
successfully on the same phone + same NDK), copied into Tauri's
gen/android/app/src/main/jniLibs at build time, and dlopened by
wzp-desktop at runtime via libloading.

Changes in this commit:
- NEW crate crates/wzp-native/ with crate-type = ["cdylib"] only
  (no staticlib, no rlib — rust#104707 shows mixing staticlib with
  cdylib leaks non-exported symbols, which is the original bug
  source). Phase 1 scaffold has TWO extern "C" functions:
    wzp_native_version() -> i32            (returns 42)
    wzp_native_hello(buf, cap) -> usize    (writes a string)
  So we can verify dlopen + dlsym + cross-.so FFI end-to-end
  before adding any real C++.
- desktop/src-tauri/cpp/ directory DELETED (7 files gone).
- desktop/src-tauri/build.rs reduced to just the git hash capture
  + tauri_build::build(). No more cc::Build of any kind.
- desktop/src-tauri/Cargo.toml: drop cc from build-dependencies,
  add libloading = "0.8" as an Android-only runtime dep.
- desktop/src-tauri/src/lib.rs Builder::setup() now (on Android only)
  dlopens libwzp_native.so, calls wzp_native_version() and
  wzp_native_hello(), and logs the result:
    "wzp-native dlopen OK: version=42 msg=\"hello from wzp-native\""
  If this log appears in logcat when the app launches and the home
  screen still renders, the split-cdylib pipeline is validated and
  Phase 2 (port the Oboe bridge into wzp-native) can proceed.
- scripts/build-tauri-android.sh: insert a `cargo ndk -t arm64-v8a
  build --release -p wzp-native` step before `cargo tauri android
  build`, with `-o desktop/src-tauri/gen/android/app/src/main/jniLibs`
  so the resulting libwzp_native.so lands in the place gradle will
  package into the final APK.
- Workspace Cargo.toml: add crates/wzp-native to [workspace] members.

Phase 2 (separate commit, only if Phase 1 works):
- Copy cpp/oboe_bridge.{h,cpp} + getauxval_fix.c from the legacy
  wzp-android crate into crates/wzp-native/cpp/.
- Add cc = "1" as a build-dependency on wzp-native (safe: it's a
  single-cdylib crate with no staticlib, so no symbol leak).
- Add build.rs that compiles the Oboe C++ and the wzp-native Rust
  FFI exposes the audio start/stop/read/write functions.
- wzp-desktop::engine.rs dlopens wzp-native at CallEngine::start,
  uses its audio functions instead of CPAL on Android.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-09 18:02:53 +04:00
parent 711137da96
commit 7cc53aedc7
14 changed files with 141 additions and 479 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "wzp-native"
version = "0.1.0"
edition = "2024"
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
# standalone .so, which is the same path the legacy wzp-android crate uses
# successfully on the same phone / same NDK. Keeping the crate-type single
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
# archive pulled bionic's internal pthread_create into the final .so.
[lib]
name = "wzp_native"
crate-type = ["cdylib"]
[dependencies]
# Phase 1 scaffold: no dependencies at all. Pure Rust FFI smoke test so we
# can validate the standalone-cdylib + libloading runtime pipeline before
# bringing Oboe / wzp-codec / etc. back in. Phase 2 will add the Oboe cc
# build and the actual audio pipeline.

View File

@@ -0,0 +1,44 @@
//! wzp-native — standalone Android cdylib for all the C++ audio code.
//!
//! This crate is built with `cargo ndk`, NOT `cargo tauri android build`,
//! because the latter mispipes link flags in a way that causes bionic's
//! private `pthread_create` / `__init_tcb` symbols to land LOCALLY inside
//! any cdylib that also has a `cc::Build::new().cpp(true)` step. See
//! `docs/incident-tauri-android-init-tcb.md` for the full post-mortem.
//!
//! The Tauri desktop crate (`wzp-desktop`) has **no C++ at all**. At
//! runtime on Android, it `libloading::Library::new("libwzp_native.so")`'s
//! this crate's .so and calls the `wzp_native_*` functions below.
//!
//! Phase 1 (this file): a tiny smoke-test FFI surface so we can validate
//! that (a) cargo-ndk happily builds this crate standalone, (b) gradle
//! picks up the resulting .so from jniLibs, (c) the Tauri cdylib can
//! dlopen us at runtime and call exported functions. No C++, no Oboe, no
//! external deps. Phase 2 will add the Oboe cc::Build + audio FFI.
/// Smoke-test export #1 — returns a fixed magic number so the Tauri cdylib
/// can assert that `dlopen + dlsym` worked end-to-end. Always returns 42.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_version() -> i32 {
42
}
/// Smoke-test export #2 — writes a fixed message into the caller's buffer
/// (NUL-terminated, capped at `cap`) and returns the number of bytes
/// written (not counting the NUL). Lets us verify we can move non-trivial
/// data across the FFI boundary without fighting ownership.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wzp_native_hello(out: *mut u8, cap: usize) -> usize {
const MSG: &[u8] = b"hello from wzp-native\0";
if out.is_null() || cap == 0 {
return 0;
}
let n = MSG.len().min(cap);
// SAFETY: caller provided a writable buffer of at least `cap` bytes.
unsafe {
core::ptr::copy_nonoverlapping(MSG.as_ptr(), out, n);
// ensure last byte is a NUL even if we had to truncate
*out.add(n - 1) = 0;
}
n - 1 // bytes written excluding the NUL
}