Files
jellytau/src-tauri/src/android_context.rs
T
dtourolle 214997144f
🏗️ 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
feat(deps): upgrade Tauri to 2.11.5, and own the Android context it stopped setting
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.
2026-08-21 22:30:28 +02:00

168 lines
6.7 KiB
Rust

//! 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()
}