feat(deps): upgrade Tauri to 2.11.5, and own the Android context it stopped setting
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m51s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Failing after 25s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m19s
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m51s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Failing after 25s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m19s
The plugin versions could not be matched upward without this: both
tauri-plugin-log 2.9.0 and tauri-plugin-updater 2.10.1 require tauri
^2.10, and the tree was on 2.9.5. So the framework moves with them --
tauri 2.9.5 -> 2.11.5, tauri-build 2.5.3 -> 2.6.3, wry 0.53.5 -> 0.55.1
-- and every plugin's Rust crate and npm package is now pinned to the
same version on both sides.
That upgrade broke Android outright, and the breakage is the interesting
part.
Seven call sites in this crate reach JNI through
ndk_context::android_context(), which reads a process-global pair of
pointers. Nothing here ever set that global. `tao` did -- the windowing
layer under wry, three levels below anything this project names in
Cargo.toml. tao 0.34.5 called initialize_android_context() while starting
the activity and our code read what it left behind. tao 0.35.3 keeps the
same two pointers in a private struct and no longer publishes them.
The result, on every launch, was:
PANIC at ndk-context/src/lib.rs:72: android context was not initialized
8: ndk_context::android_context
9: jellytau_lib::run::{{closure}}
Not a crash in our code, and not a change to our code: an undocumented
side effect of a transitive dependency disappeared. Relying on someone
else to populate a global is a dependency that does not appear in
Cargo.toml and gives no warning when it goes.
src-tauri/src/android_context.rs now owns that invariant instead of
assuming it. JNI_OnLoad captures the JavaVM as the shared library loads
-- the earliest moment available, and nothing in tao, wry or tauri
defines one to collide with. The Context is resolved lazily via
ActivityThread.currentApplication() and pinned as a global reference for
the process lifetime, since ndk_context stores a bare pointer and does
not own it. It publishes the Application rather than the Activity:
SecureStorage.initialize() immediately reduces its argument to
applicationContext anyway, and an Application cannot outlive itself the
way a retained Activity would.
Restoring the global keeps all seven callers untouched. Threading a VM
and Context handle through five credential call sites would have been a
larger change with more risk, on the credential path.
Failure now degrades instead of aborting: it is logged and credentials
fall back to the encrypted-file path, which the app already supports.
Verified on a device, R8-minified, not merely compiled:
[INIT] Android JavaVM and Application published to ndk_context
Android SecureStorage initialized successfully
Android Keystore available via SecureStorage
[INIT] Using system keyring for credential storage
[CodecDetection] Detected 7 video codecs: av1,h263,h264,hevc,...
-- the real keystore path, not the fallback, and the app stays up. None
of this is reachable by CI: nothing there runs the app.
Also fixed here, both found the same way:
- `tauri android build --apk true` is now `--apk`. The CLI took a value
until 2.10; from 2.11 the stray `true` is a positional and the build
fails before starting. Three call sites in build-android.sh and one
in build-release.yml -- the latter builds the signed APK, by far the
most-downloaded artifact.
- scripts/build-android.sh ran `npm install` on its clean-build path in
a bun project, ignoring bun.lock and re-resolving the tree. That is
exactly how the plugin crate/package versions drift apart again.
scripts/check-tooling.sh now fails on any npm/yarn/pnpm invocation or
foreign lockfile, and runs in CI.
DR-222, DR-223.
This commit is contained in:
Generated
+559
-376
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
//! Publishes the Android JavaVM and application Context into `ndk_context`.
|
||||
//!
|
||||
//! TRACES: UR-012 | DR-223
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Seven places in this crate (five in `credentials.rs`, two in `lib.rs`) reach
|
||||
//! the JNI environment through [`ndk_context::android_context`], which reads a
|
||||
//! process-global pair of pointers: the `JavaVM` and a `Context` jobject.
|
||||
//!
|
||||
//! Nothing here ever set that global. `tao` did — the windowing layer beneath
|
||||
//! `wry`, several dependencies below anything this project names. tao 0.34.5
|
||||
//! called `ndk_context::initialize_android_context(...)` while starting the
|
||||
//! Android activity, and our code simply read what it had left behind.
|
||||
//!
|
||||
//! **tao 0.35.3 stopped.** It keeps the same two pointers in a private
|
||||
//! `AndroidContext` struct of its own and no longer publishes them. The moment
|
||||
//! that landed (via the Tauri 2.9.5 → 2.11.5 upgrade), the first credential
|
||||
//! read on Android aborted the process:
|
||||
//!
|
||||
//! ```text
|
||||
//! PANIC at ndk-context/src/lib.rs:72: android context was not initialized
|
||||
//! 8: ndk_context::android_context
|
||||
//! 9: jellytau_lib::run::{{closure}}
|
||||
//! ```
|
||||
//!
|
||||
//! Not a crash in our code, and not a change to our code: an undocumented side
|
||||
//! effect of a transitive dependency disappeared. The lesson worth keeping is
|
||||
//! that relying on *someone else* to populate a global is a dependency you
|
||||
//! cannot see in `Cargo.toml` and will not be told about when it breaks.
|
||||
//!
|
||||
//! # Why restore the global rather than rewrite the call sites
|
||||
//!
|
||||
//! Threading a VM and Context handle through seven call sites — including the
|
||||
//! credential path — is a larger and riskier change than owning the invariant
|
||||
//! those call sites already depend on. This module makes the assumption true
|
||||
//! instead of removing it, and the seven callers are untouched.
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! `JNI_OnLoad` gives us the `JavaVM` the instant the shared library loads,
|
||||
//! which is the earliest and most reliable moment available — nothing in the app
|
||||
//! can run before it. It does *not* give us a Context, so that is resolved
|
||||
//! lazily on first use via `ActivityThread.currentApplication()`, by which point
|
||||
//! the Application object certainly exists.
|
||||
//!
|
||||
//! The Context published is the **Application**, not the Activity. That is what
|
||||
//! the consumers want anyway (`SecureStorage.initialize()` immediately calls
|
||||
//! `context.applicationContext`), and it cannot outlive its own lifetime the way
|
||||
//! a retained Activity reference would.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicPtr, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jni::objects::GlobalRef;
|
||||
use jni::sys::{jint, JNI_VERSION_1_6};
|
||||
use jni::JavaVM;
|
||||
|
||||
/// The `JavaVM`, captured at library load.
|
||||
static JAVA_VM: AtomicPtr<c_void> = AtomicPtr::new(std::ptr::null_mut());
|
||||
|
||||
/// A global reference to the Application, kept alive for the process lifetime.
|
||||
///
|
||||
/// `ndk_context` stores a bare pointer and does not own the reference, so the
|
||||
/// `GlobalRef` must outlive every read. A local reference would be freed the
|
||||
/// moment the frame that created it returned, leaving a dangling jobject that
|
||||
/// only misbehaves later.
|
||||
static APP_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();
|
||||
|
||||
/// Whether the `ndk_context` global has been populated.
|
||||
static PUBLISHED: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Called by the Android runtime when `libjellytau_lib.so` is loaded.
|
||||
///
|
||||
/// Verified that neither tao, wry nor tauri defines `JNI_OnLoad` in this
|
||||
/// library, so there is nothing to collide with. Returning the JNI version is
|
||||
/// mandatory — returning 0 makes `System.loadLibrary` fail.
|
||||
///
|
||||
/// TRACES: UR-012 | DR-223
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint {
|
||||
JAVA_VM.store(vm.get_java_vm_pointer().cast(), Ordering::SeqCst);
|
||||
// Deliberately no logging here: the logger is not installed this early.
|
||||
JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
/// Make [`ndk_context::android_context`] safe to call.
|
||||
///
|
||||
/// Idempotent and cheap after the first success. Returns an error rather than
|
||||
/// panicking: a failure here means credentials fall back to the encrypted-file
|
||||
/// path, which is a degraded mode the app already supports — far better than
|
||||
/// aborting the process, which is what the missing global did.
|
||||
///
|
||||
/// TRACES: UR-012 | DR-223
|
||||
pub fn ensure_initialized() -> Result<(), String> {
|
||||
if PUBLISHED.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vm_ptr = JAVA_VM.load(Ordering::SeqCst);
|
||||
if vm_ptr.is_null() {
|
||||
return Err(
|
||||
"JNI_OnLoad has not run: no JavaVM captured. The library was loaded in an \
|
||||
unexpected way, or JNI_OnLoad was stripped from the shared object."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
|
||||
.map_err(|e| format!("failed to adopt the JavaVM pointer: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("failed to attach the current thread to the JVM: {e}"))?;
|
||||
|
||||
// ActivityThread.currentApplication() is the standard way to reach the
|
||||
// Application from native code without being handed a Context. It is a
|
||||
// hidden-but-stable API; it returns null only before the Application is
|
||||
// constructed, which cannot be the case by the time anything here runs.
|
||||
let activity_thread = env
|
||||
.find_class("android/app/ActivityThread")
|
||||
.map_err(|e| format!("android.app.ActivityThread not found: {e}"))?;
|
||||
|
||||
let application = env
|
||||
.call_static_method(
|
||||
activity_thread,
|
||||
"currentApplication",
|
||||
"()Landroid/app/Application;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("ActivityThread.currentApplication() failed: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("currentApplication() did not return an object: {e}"))?;
|
||||
|
||||
if application.is_null() {
|
||||
return Err(
|
||||
"ActivityThread.currentApplication() returned null — the Application has not \
|
||||
been created yet."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let global = env
|
||||
.new_global_ref(&application)
|
||||
.map_err(|e| format!("failed to pin the Application as a global reference: {e}"))?;
|
||||
|
||||
// Store first, publish second: `ndk_context` will hold a bare pointer into
|
||||
// this reference, so it must already be owned somewhere permanent.
|
||||
let stored = APP_CONTEXT.get_or_init(|| global);
|
||||
let context_ptr = stored.as_obj().as_raw().cast::<c_void>();
|
||||
|
||||
unsafe {
|
||||
ndk_context::initialize_android_context(vm_ptr, context_ptr);
|
||||
}
|
||||
let _ = PUBLISHED.set(true);
|
||||
|
||||
log::info!("[INIT] Android JavaVM and Application published to ndk_context");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the global has been published, for callers that want to degrade
|
||||
/// rather than attempt a JNI call.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_initialized() -> bool {
|
||||
PUBLISHED.get().is_some()
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_context;
|
||||
mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
@@ -614,6 +616,14 @@ fn create_player_backend(
|
||||
{
|
||||
info!("Android platform detected - initializing ExoPlayer backend");
|
||||
|
||||
// Same precondition as the credential path: ndk_context must be
|
||||
// populated before it is read, and nothing outside this crate populates
|
||||
// it any more. Idempotent, so it does not matter which of the two runs
|
||||
// first. TRACES: UR-012 | DR-223
|
||||
if let Err(e) = crate::android_context::ensure_initialized() {
|
||||
log::error!("[INIT] Android context unavailable for the player: {e}");
|
||||
}
|
||||
|
||||
// Get the Android context via ndk-context
|
||||
let ctx = ndk_context::android_context();
|
||||
|
||||
@@ -1270,6 +1280,21 @@ pub fn run() {
|
||||
// On Android, initialize SecureStorage BEFORE creating CredentialStore
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Publish the JavaVM and Application into ndk_context first.
|
||||
//
|
||||
// Everything below reads that global. tao used to populate it
|
||||
// and stopped doing so in 0.35 (Tauri 2.11), at which point the
|
||||
// first read here aborted the process on launch. See
|
||||
// android_context.rs. A failure is logged rather than fatal:
|
||||
// credentials then fall back to the encrypted-file path, which
|
||||
// is a supported degraded mode -- unlike aborting.
|
||||
//
|
||||
// TRACES: UR-012 | DR-223
|
||||
if let Err(e) = crate::android_context::ensure_initialized() {
|
||||
log::error!("[INIT] Android context unavailable: {e}");
|
||||
log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
|
||||
}
|
||||
|
||||
info!("[INIT] Initializing Android SecureStorage for credentials...");
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||
|
||||
Reference in New Issue
Block a user