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:
Generated
+1
@@ -7306,6 +7306,7 @@ name = "solitaire_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
|
"jni 0.21.1",
|
||||||
"keyring",
|
"keyring",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
|
|||||||
+7
-6
@@ -19,13 +19,14 @@ license = "MIT"
|
|||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
# Pedantic correctness lints applied across every member crate via
|
# Pedantic correctness lints applied across every member crate via
|
||||||
# `[lints] workspace = true`. `unsafe_code` is "deny" rather than "forbid"
|
# `[lints] workspace = true`.
|
||||||
# so the three Android JNI FFI modules can opt back in with a scoped
|
|
||||||
# `#![allow(unsafe_code)]` — `forbid` cannot be locally overridden, which
|
|
||||||
# would break the Android build. Pure crates (core, sync) carry no `unsafe`
|
|
||||||
# and so remain effectively forbidden in practice.
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "deny"
|
# Workspace-wide ban on `unsafe`. The sole exception is `solitaire_app`,
|
||||||
|
# which sets its own `deny`-level lints (see its Cargo.toml) because the
|
||||||
|
# Android cdylib entry point must reconstruct raw JNI handles. Every other
|
||||||
|
# crate reaches Android JNI through the safe `solitaire_data::android_jni`
|
||||||
|
# bridge and stays fully unsafe-free.
|
||||||
|
unsafe_code = "forbid"
|
||||||
single_use_lifetimes = "warn"
|
single_use_lifetimes = "warn"
|
||||||
trivial_casts = "warn"
|
trivial_casts = "warn"
|
||||||
unused_lifetimes = "warn"
|
unused_lifetimes = "warn"
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ bevy = { workspace = true }
|
|||||||
solitaire_engine = { workspace = true }
|
solitaire_engine = { workspace = true }
|
||||||
solitaire_data = { workspace = true }
|
solitaire_data = { workspace = true }
|
||||||
|
|
||||||
|
# Android-only: the entry point reconstructs the raw `JavaVM` / activity
|
||||||
|
# handles and registers the safe `solitaire_data::android_jni` bridge. This
|
||||||
|
# is the one crate in the workspace that performs `unsafe` FFI, so it is also
|
||||||
|
# the only one that depends on `jni` directly at the app layer.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = { workspace = true }
|
||||||
|
|
||||||
# Desktop-only deps. `keyring`'s default-store init only matters on
|
# Desktop-only deps. `keyring`'s default-store init only matters on
|
||||||
# platforms with a real keychain backend (Linux Secret Service,
|
# platforms with a real keychain backend (Linux Secret Service,
|
||||||
# macOS Keychain, Windows Credential Store), and its transitive
|
# macOS Keychain, Windows Credential Store), and its transitive
|
||||||
@@ -100,5 +107,17 @@ icon = "@mipmap/ic_launcher"
|
|||||||
# enabling auto-rotate.
|
# enabling auto-rotate.
|
||||||
orientation = "portrait"
|
orientation = "portrait"
|
||||||
|
|
||||||
[lints]
|
# `solitaire_app` is the one crate that cannot inherit the workspace
|
||||||
workspace = true
|
# `forbid(unsafe_code)`: as the Android cdylib it must export the
|
||||||
|
# `#[unsafe(no_mangle)]` entry point and reconstruct the raw JNI handles
|
||||||
|
# there (a `no_mangle` symbol cannot live in a dependency rlib). It mirrors
|
||||||
|
# the workspace lints but at `deny`, so the two `#[allow(unsafe_code)]`
|
||||||
|
# scopes in the Android entry point are the only unsafe in the whole tree.
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
single_use_lifetimes = "warn"
|
||||||
|
trivial_casts = "warn"
|
||||||
|
unused_lifetimes = "warn"
|
||||||
|
unused_qualifications = "warn"
|
||||||
|
variant_size_differences = "warn"
|
||||||
|
unexpected_cfgs = "warn"
|
||||||
|
|||||||
@@ -363,16 +363,57 @@ fn set_window_icon(
|
|||||||
/// works on a function named `main`; our shared entry point is `run`, so
|
/// works on a function named `main`; our shared entry point is `run`, so
|
||||||
/// we emit the equivalent expansion manually.
|
/// we emit the equivalent expansion manually.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(unsafe_code)]
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
fn android_main(android_app: bevy::android::android_activity::AndroidApp) {
|
||||||
let vm_ptr = android_app.vm_as_ptr().cast();
|
if let Err(e) = init_android_jni(&android_app) {
|
||||||
if let Err(e) = solitaire_data::init_android_jvm(vm_ptr) {
|
eprintln!("warn: could not initialise Android JNI bridge ({e})");
|
||||||
eprintln!("warn: could not initialise Android Keystore JNI ({e})");
|
|
||||||
}
|
}
|
||||||
let _ = bevy::android::ANDROID_APP.set(android_app);
|
let _ = bevy::android::ANDROID_APP.set(android_app);
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconstructs the raw `JavaVM` / `NativeActivity` handles handed over by the
|
||||||
|
/// Android runtime and registers safe wrappers with `solitaire_data`.
|
||||||
|
///
|
||||||
|
/// This is the *only* place in the workspace that performs `unsafe` FFI handle
|
||||||
|
/// reconstruction. Every other crate consumes the safe
|
||||||
|
/// [`solitaire_data::android_jni`] bridge and stays `forbid(unsafe_code)`;
|
||||||
|
/// `solitaire_app` opts down to `deny` with a narrowly scoped allow on this
|
||||||
|
/// function and the `#[unsafe(no_mangle)]` entry point above.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(unsafe_code)]
|
||||||
|
fn init_android_jni(
|
||||||
|
android_app: &bevy::android::android_activity::AndroidApp,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use jni::JavaVM;
|
||||||
|
use jni::objects::JObject;
|
||||||
|
|
||||||
|
let vm_ptr = android_app.vm_as_ptr();
|
||||||
|
if vm_ptr.is_null() {
|
||||||
|
return Err("JavaVM pointer is null".into());
|
||||||
|
}
|
||||||
|
// SAFETY: `vm_as_ptr()` returns the process-wide JavaVM* established by the
|
||||||
|
// Android runtime; it is valid for the lifetime of the process.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }.map_err(|e| format!("JavaVM: {e}"))?;
|
||||||
|
|
||||||
|
let env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
||||||
|
|
||||||
|
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject pointer,
|
||||||
|
// valid for the lifetime of the process. Promote it to a global reference
|
||||||
|
// so the safe bridge can hand it to any thread.
|
||||||
|
let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
|
||||||
|
let activity_ref = env
|
||||||
|
.new_global_ref(&activity)
|
||||||
|
.map_err(|e| format!("activity global ref: {e}"))?;
|
||||||
|
|
||||||
|
solitaire_data::android_jni::set_jvm(vm);
|
||||||
|
solitaire_data::android_jni::set_activity(activity_ref);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Wraps the default panic hook with one that also appends a crash log
|
/// Wraps the default panic hook with one that also appends a crash log
|
||||||
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
||||||
/// still runs afterwards, so stderr output and debugger integration are
|
/// still runs afterwards, so stderr output and debugger integration are
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JByteArray` handles
|
|
||||||
// from raw pointers handed over by the Android runtime. Scoped to this
|
|
||||||
// module so the rest of the workspace stays `deny(unsafe_code)`.
|
|
||||||
#![allow(unsafe_code)]
|
|
||||||
|
|
||||||
/// Android Keystore token storage via JNI.
|
/// Android Keystore token storage via JNI.
|
||||||
///
|
///
|
||||||
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
|
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
|
||||||
@@ -19,19 +14,16 @@
|
|||||||
///
|
///
|
||||||
/// Only compiled and linked on `target_os = "android"`.
|
/// Only compiled and linked on `target_os = "android"`.
|
||||||
use jni::{
|
use jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv,
|
||||||
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ffi::c_void;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use crate::auth_tokens::TokenError;
|
use crate::auth_tokens::TokenError;
|
||||||
|
|
||||||
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
||||||
static ANDROID_JVM: OnceLock<JavaVM> = OnceLock::new();
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct TokenBlob {
|
struct TokenBlob {
|
||||||
@@ -44,43 +36,15 @@ struct TokenBlob {
|
|||||||
// JVM helper
|
// JVM helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Initialise Android Keystore access with the process-wide `JavaVM*`.
|
/// Run `f` with an attached `JNIEnv`, delegating thread attach and the
|
||||||
///
|
/// `JavaVM` handle to the safe [`crate::android_jni`] bridge. The bridge is
|
||||||
/// This is called by `solitaire_app` from Android startup code. Keeping the
|
/// initialised once from Android startup, so the keystore never touches a raw
|
||||||
/// raw JVM pointer here avoids making `solitaire_data` depend on the app or
|
/// pointer and this module stays `forbid(unsafe_code)`.
|
||||||
/// engine layer just to reach platform startup state.
|
|
||||||
pub fn init_android_jvm(vm_ptr: *mut c_void) -> Result<(), TokenError> {
|
|
||||||
if vm_ptr.is_null() {
|
|
||||||
return Err(TokenError::KeychainUnavailable(
|
|
||||||
"JavaVM pointer is null".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if ANDROID_JVM.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: `vm_ptr` is supplied by Android startup code and must be the
|
|
||||||
// process-wide JavaVM* for this app. `OnceLock` keeps the wrapper alive for
|
|
||||||
// the process lifetime.
|
|
||||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
|
|
||||||
.map_err(|e| TokenError::Keyring(format!("JavaVM: {e}")))?;
|
|
||||||
let _ = ANDROID_JVM.set(vm);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
fn with_jvm<F, R>(f: F) -> Result<R, TokenError>
|
||||||
where
|
where
|
||||||
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
F: for<'env> FnOnce(&mut JNIEnv<'env>) -> Result<R, jni::errors::Error>,
|
||||||
{
|
{
|
||||||
let vm = ANDROID_JVM
|
crate::android_jni::with_env(f).map_err(TokenError::Keyring)
|
||||||
.get()
|
|
||||||
.ok_or_else(|| TokenError::KeychainUnavailable("Android JavaVM not initialised".into()))?;
|
|
||||||
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread_permanently()
|
|
||||||
.map_err(|e| TokenError::Keyring(format!("attach: {e}")))?;
|
|
||||||
|
|
||||||
f(&mut env).map_err(|e| TokenError::Keyring(format!("JNI: {e}")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -235,9 +199,10 @@ fn encrypt_gcm(
|
|||||||
.v()?;
|
.v()?;
|
||||||
|
|
||||||
// IV is generated by Android's provider; read it back after init.
|
// IV is generated by Android's provider; read it back after init.
|
||||||
|
// `getIV()` returns `[B`; the safe `From<JObject>` reinterprets the
|
||||||
|
// returned object reference as a typed byte array.
|
||||||
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
let iv_jobj = env.call_method(&cipher, "getIV", "()[B", &[])?.l()?;
|
||||||
// SAFETY: the method signature guarantees a byte array return.
|
let iv_arr = JByteArray::from(iv_jobj);
|
||||||
let iv_arr = unsafe { JByteArray::from_raw(iv_jobj.into_raw()) };
|
|
||||||
let iv = env.convert_byte_array(&iv_arr)?;
|
let iv = env.convert_byte_array(&iv_arr)?;
|
||||||
|
|
||||||
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
let pt_arr = env.byte_array_from_slice(plaintext)?;
|
||||||
@@ -245,8 +210,7 @@ fn encrypt_gcm(
|
|||||||
let ct_jobj = env
|
let ct_jobj = env
|
||||||
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
.call_method(&cipher, "doFinal", "([B)[B", &[pt_val.borrow()])?
|
||||||
.l()?;
|
.l()?;
|
||||||
// SAFETY: doFinal([B) returns [B.
|
let ct_arr = JByteArray::from(ct_jobj);
|
||||||
let ct_arr = unsafe { JByteArray::from_raw(ct_jobj.into_raw()) };
|
|
||||||
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
let ciphertext = env.convert_byte_array(&ct_arr)?;
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
let mut out = Vec::with_capacity(iv.len() + ciphertext.len());
|
||||||
@@ -297,8 +261,7 @@ fn decrypt_gcm(
|
|||||||
let pt_jobj = env
|
let pt_jobj = env
|
||||||
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
.call_method(&cipher, "doFinal", "([B)[B", &[ct_val.borrow()])?
|
||||||
.l()?;
|
.l()?;
|
||||||
// SAFETY: doFinal([B) returns [B.
|
let pt_arr = JByteArray::from(pt_jobj);
|
||||||
let pt_arr = unsafe { JByteArray::from_raw(pt_jobj.into_raw()) };
|
|
||||||
env.convert_byte_array(&pt_arr)
|
env.convert_byte_array(&pt_arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,8 +348,9 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Legacy path migration ---
|
// --- 2. Legacy path migration ---
|
||||||
if let Some(ref lpath) = legacy_path {
|
if let Some(ref lpath) = legacy_path
|
||||||
if lpath.exists() {
|
&& lpath.exists()
|
||||||
|
{
|
||||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||||
other => other,
|
other => other,
|
||||||
@@ -408,7 +372,6 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
|||||||
}
|
}
|
||||||
// Legacy file corrupt or unrecognised — treat as empty.
|
// Legacy file corrupt or unrecognised — treat as empty.
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3. No file found ---
|
// --- 3. No file found ---
|
||||||
Ok(HashMap::new())
|
Ok(HashMap::new())
|
||||||
@@ -496,12 +459,12 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
|||||||
|
|
||||||
if map.is_empty() {
|
if map.is_empty() {
|
||||||
// No more users — remove the file and the Keystore key.
|
// No more users — remove the file and the Keystore key.
|
||||||
if let Some(path) = token_file_path() {
|
if let Some(path) = token_file_path()
|
||||||
if path.exists() {
|
&& path.exists()
|
||||||
|
{
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path)
|
||||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||||
with_jvm(|env| {
|
with_jvm(|env| {
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
//! `keyring-core` cannot compile for the android target (its `rpassword`
|
||||||
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
//! transitive dep uses `libc::__errno_location`, which Android's bionic
|
||||||
//! doesn't expose). On Android this module delegates to an Android Keystore
|
//! doesn't expose). On Android this module delegates to an Android Keystore
|
||||||
//! JNI backend. `solitaire_app` must call `solitaire_data::init_android_jvm`
|
//! JNI backend. `solitaire_app` must initialise the safe
|
||||||
//! from Android startup before token operations can succeed.
|
//! [`crate::android_jni`] bridge (via `set_jvm` / `set_activity`) from Android
|
||||||
|
//! startup before token operations can succeed.
|
||||||
//!
|
//!
|
||||||
//! # Note: no unit tests — requires live OS keychain.
|
//! # Note: no unit tests — requires live OS keychain.
|
||||||
|
|
||||||
|
|||||||
@@ -144,9 +144,10 @@ pub use settings::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod android_keystore;
|
pub mod android_jni;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub use android_keystore::init_android_jvm;
|
mod android_keystore;
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
|||||||
@@ -1,42 +1,19 @@
|
|||||||
// JNI FFI requires `unsafe` to reconstruct `JavaVM` / `JObject` handles from
|
|
||||||
// raw pointers handed over by the Android runtime. Scoped to this module so
|
|
||||||
// the rest of the workspace stays `deny(unsafe_code)`.
|
|
||||||
#![allow(unsafe_code)]
|
|
||||||
|
|
||||||
/// Android clipboard bridge via JNI.
|
/// Android clipboard bridge via JNI.
|
||||||
///
|
///
|
||||||
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
/// Writes text to the system clipboard by calling into `ClipboardManager`
|
||||||
/// through the JNI. Only compiled and linked on `target_os = "android"`.
|
/// through the safe [`solitaire_data::android_jni`] bridge. Only compiled and
|
||||||
|
/// linked on `target_os = "android"`.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn set_text(text: &str) -> Result<(), String> {
|
pub fn set_text(text: &str) -> Result<(), String> {
|
||||||
use bevy::android::ANDROID_APP;
|
use jni::objects::JValueOwned;
|
||||||
use jni::{
|
use solitaire_data::android_jni;
|
||||||
JavaVM,
|
|
||||||
objects::{JObject, JValueOwned},
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = ANDROID_APP
|
android_jni::with_activity_env(|env, activity| {
|
||||||
.get()
|
|
||||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
|
||||||
|
|
||||||
// SAFETY: vm_as_ptr() returns the raw JavaVM* set up by the Android runtime.
|
|
||||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
|
||||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
|
||||||
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread_permanently()
|
|
||||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
||||||
|
|
||||||
// SAFETY: activity_as_ptr() is the NativeActivity jobject pointer —
|
|
||||||
// valid for the lifetime of the process.
|
|
||||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
|
||||||
|
|
||||||
(|| -> jni::errors::Result<()> {
|
|
||||||
// ClipboardManager cm = activity.getSystemService("clipboard")
|
// ClipboardManager cm = activity.getSystemService("clipboard")
|
||||||
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
let svc_name = JValueOwned::from(env.new_string("clipboard")?);
|
||||||
let cm = env
|
let cm = env
|
||||||
.call_method(
|
.call_method(
|
||||||
&activity,
|
activity,
|
||||||
"getSystemService",
|
"getSystemService",
|
||||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
&[svc_name.borrow()],
|
&[svc_name.borrow()],
|
||||||
@@ -65,6 +42,5 @@ pub fn set_text(text: &str) -> Result<(), String> {
|
|||||||
&[clip_val.borrow()],
|
&[clip_val.borrow()],
|
||||||
)?
|
)?
|
||||||
.v()
|
.v()
|
||||||
})()
|
})
|
||||||
.map_err(|e| format!("clipboard JNI: {e}"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
//! Safe-area insets.
|
//! Safe-area insets.
|
||||||
//!
|
//!
|
||||||
// JNI FFI (Android only) requires `unsafe` to reconstruct `JavaVM` /
|
|
||||||
// `JObject` handles from raw pointers handed over by the runtime. Scoped to
|
|
||||||
// this module so the rest of the workspace stays `deny(unsafe_code)`.
|
|
||||||
#![allow(unsafe_code)]
|
|
||||||
//!
|
|
||||||
//! Reports the OS-reserved regions around the playable surface (status
|
//! Reports the OS-reserved regions around the playable surface (status
|
||||||
//! bar at the top, gesture / navigation bar at the bottom on Android,
|
//! bar at the top, gesture / navigation bar at the bottom on Android,
|
||||||
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
|
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
|
||||||
@@ -296,30 +291,12 @@ mod android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use bevy::android::ANDROID_APP;
|
use solitaire_data::android_jni;
|
||||||
use jni::{JavaVM, objects::JObject};
|
|
||||||
|
|
||||||
let app = ANDROID_APP
|
android_jni::with_activity_env(|env, activity| {
|
||||||
.get()
|
|
||||||
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
|
|
||||||
|
|
||||||
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
|
|
||||||
// runtime; valid for the lifetime of the process.
|
|
||||||
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
|
|
||||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
|
||||||
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread_permanently()
|
|
||||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
||||||
|
|
||||||
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
|
|
||||||
// pointer — valid for the lifetime of the process.
|
|
||||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
|
|
||||||
|
|
||||||
(|| -> jni::errors::Result<SafeAreaInsets> {
|
|
||||||
// Window window = activity.getWindow();
|
// Window window = activity.getWindow();
|
||||||
let window = env
|
let window = env
|
||||||
.call_method(&activity, "getWindow", "()Landroid/view/Window;", &[])?
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
.l()?;
|
.l()?;
|
||||||
|
|
||||||
// View decor = window.getDecorView();
|
// View decor = window.getDecorView();
|
||||||
@@ -371,8 +348,7 @@ mod android {
|
|||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
})
|
})
|
||||||
})()
|
})
|
||||||
.map_err(|e| format!("safe-area JNI: {e}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user