refactor(android): forbid unsafe workspace-wide, quarantine JNI in app (#91)
Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered `#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the unsafe rather than relocating it, then forbidding it everywhere it can be forbidden. Changes: - solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM` and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so keystore/clipboard/safe-area never touch a raw handle. - keystore: drop the `JavaVM::from_raw` init path (now in the app) and replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides. - engine clipboard + safe_area: route through the bridge; remove their `#![allow(unsafe_code)]` and all `from_raw` calls. - solitaire_app: becomes the single owner of FFI unsafe. `android_main` reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`. - workspace: `unsafe_code` is now `forbid`. Every crate except the app entry point is fully unsafe-free. Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in one crate. Verified with host `clippy --workspace -- -D warnings` and an `aarch64-linux-android` clippy build of solitaire_app (transitively engine + data); also fixed two latent android-only `collapsible_if` warnings surfaced in the keystore by the cross-target check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
//! Safe JNI bridge for Android platform integration.
|
||||
//!
|
||||
//! Reconstructing the raw `JavaVM` / `NativeActivity` handles handed over by
|
||||
//! the Android runtime requires `unsafe` FFI. That single reconstruction lives
|
||||
//! in `solitaire_app`'s entry point ([`solitaire_app::android_main`]); this
|
||||
//! module only ever stores the resulting *safe* [`JavaVM`] and activity
|
||||
//! [`GlobalRef`] and hands callers an attached [`JNIEnv`].
|
||||
//!
|
||||
//! As a result every consumer of Android JNI — the keystore, clipboard, and
|
||||
//! safe-area subsystems — goes through the safe functions here and stays
|
||||
//! `forbid(unsafe_code)`. The only crate carrying `unsafe` is `solitaire_app`.
|
||||
//!
|
||||
//! Only compiled and linked on `target_os = "android"`.
|
||||
|
||||
use jni::objects::{GlobalRef, JObject};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
||||
static ANDROID_ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
|
||||
|
||||
/// Store the process-wide [`JavaVM`]. Called once from Android startup
|
||||
/// (`solitaire_app::android_main`); subsequent calls are ignored.
|
||||
pub fn set_jvm(vm: JavaVM) {
|
||||
let _ = ANDROID_JVM.set(vm);
|
||||
}
|
||||
|
||||
/// Store a global reference to the `NativeActivity`. Called once from Android
|
||||
/// startup; subsequent calls are ignored.
|
||||
pub fn set_activity(activity: GlobalRef) {
|
||||
let _ = ANDROID_ACTIVITY.set(activity);
|
||||
}
|
||||
|
||||
/// Run `f` with a [`JNIEnv`] attached to the current thread.
|
||||
///
|
||||
/// Returns an error string if the bridge has not been initialised yet or the
|
||||
/// thread cannot be attached. The closure's JNI errors are surfaced through the
|
||||
/// same `String` channel so callers have a single error type to map.
|
||||
pub fn with_env<F, R>(f: F) -> Result<R, String>
|
||||
where
|
||||
F: for<'local> FnOnce(&mut JNIEnv<'local>) -> jni::errors::Result<R>,
|
||||
{
|
||||
let vm = ANDROID_JVM
|
||||
.get()
|
||||
.ok_or_else(|| "Android JavaVM not initialised".to_string())?;
|
||||
let mut env = vm
|
||||
.attach_current_thread_permanently()
|
||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||
f(&mut env).map_err(|e| format!("JNI: {e}"))
|
||||
}
|
||||
|
||||
/// Run `f` with an attached [`JNIEnv`] and the `NativeActivity` object.
|
||||
///
|
||||
/// Like [`with_env`] but also resolves the cached activity global reference, so
|
||||
/// callers that need to invoke instance methods on the activity (clipboard,
|
||||
/// window insets) never touch a raw handle.
|
||||
pub fn with_activity_env<F, R>(f: F) -> Result<R, String>
|
||||
where
|
||||
F: for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> jni::errors::Result<R>,
|
||||
{
|
||||
let activity = ANDROID_ACTIVITY
|
||||
.get()
|
||||
.ok_or_else(|| "Android activity not initialised".to_string())?;
|
||||
with_env(|env| f(env, activity.as_obj()))
|
||||
}
|
||||
Reference in New Issue
Block a user