use std::sync::{Mutex, OnceLock};
pub fn init_logging() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
let _ = log::set_logger(&LOGCAT_LOGGER);
log::set_max_level(log::LevelFilter::Info);
});
}
static LOGCAT_LOGGER: LogcatLogger = LogcatLogger;
struct LogcatLogger;
fn logcat_priority(level: log::Level) -> i32 {
match level {
log::Level::Error => 6,
log::Level::Warn => 5,
log::Level::Info => 4,
log::Level::Debug => 3,
log::Level::Trace => 2,
}
}
impl log::Log for LogcatLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let tag = std::ffi::CString::new("rust_widgets").unwrap_or_default();
let message = std::ffi::CString::new(record.args().to_string()).unwrap_or_default();
unsafe {
let _ =
android_log_write(logcat_priority(record.level()), tag.as_ptr(), message.as_ptr());
}
}
fn flush(&self) {}
}
extern "C" {
fn __android_log_write(prio: i32, tag: *const i8, text: *const i8) -> i32;
}
unsafe fn android_log_write(prio: i32, tag: *const i8, text: *const i8) -> i32 {
unsafe { __android_log_write(prio, tag, text) }
}
static JAVA_VM: OnceLock<jni::JavaVM> = OnceLock::new();
fn activity_context_slot() -> &'static Mutex<Option<jni::objects::GlobalRef>> {
static SLOT: OnceLock<Mutex<Option<jni::objects::GlobalRef>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
pub fn set_activity_context(
env: &mut jni::JNIEnv<'_>,
context: &jni::objects::JObject<'_>,
) -> bool {
let Ok(global) = env.new_global_ref(context) else {
log::error!("[android-jni] nativeAttachContext: failed to create a global reference");
return false;
};
let mut slot = activity_context_slot().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
*slot = Some(global);
log::info!("[android-jni] Activity Context attached");
true
}
pub fn has_activity_context() -> bool {
activity_context_slot().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).is_some()
}
pub fn native_view_creation_ready() -> bool {
is_initialized() && has_activity_context()
}
pub fn is_initialized() -> bool {
JAVA_VM.get().is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IntegrationStatus {
pub jni_initialized: bool,
pub context_attached: bool,
pub native_methods_count: u32,
pub ready: bool,
}
pub fn android_integration_ready() -> IntegrationStatus {
let jni_initialized = is_initialized();
let context_attached = has_activity_context();
IntegrationStatus {
jni_initialized,
context_attached,
native_methods_count: NATIVE_METHOD_COUNT,
ready: jni_initialized && context_attached,
}
}
pub const NATIVE_METHOD_COUNT: u32 = 7;
pub fn with_jni_env<F, R>(f: F) -> Option<R>
where
F: FnOnce(&mut jni::JNIEnv<'_>) -> R,
{
let vm = JAVA_VM.get()?;
let mut env = vm.attach_current_thread().ok()?;
Some(f(&mut env))
}
pub fn launch_file_dialog(mime_type: &str) -> bool {
let Some(result) = with_jni_env(|env| launch_document_picker(env, mime_type)) else {
log::warn!(
"[android-jni] {mime_type}: no JavaVM is stored, so the document picker cannot be \
launched; nativeInit has not run"
);
return false;
};
result
}
fn launch_document_picker(env: &mut jni::JNIEnv<'_>, mime_type: &str) -> bool {
let context = {
let slot = activity_context_slot().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match slot.as_ref() {
Some(global) => global.clone(),
None => {
log::warn!(
"[android-jni] {mime_type}: no Activity Context is attached, so the document \
picker cannot be launched; the host app must call nativeAttachContext"
);
return false;
}
}
};
let Ok(mime) = env.new_string(mime_type) else {
log::error!("[android-jni] failed to allocate the mime type string");
return false;
};
let result = (|| -> jni::errors::Result<()> {
let intent_class = env.find_class("android/content/Intent")?;
let action = env.new_string("android.intent.action.OPEN_DOCUMENT")?;
let intent = env.new_object(
&intent_class,
"(Ljava/lang/String;)V",
&[jni::objects::JValue::Object(&action)],
)?;
env.call_method(
&intent,
"setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[jni::objects::JValue::Object(&mime)],
)?;
env.call_method(
context.as_obj(),
"startActivityForResult",
"(Landroid/content/Intent;I)V",
&[jni::objects::JValue::Object(&intent), jni::objects::JValue::Int(0)],
)?;
Ok(())
})();
match result {
Ok(()) => {
log::info!("[android-jni] document picker launched for {mime_type}");
true
}
Err(error) => {
log::error!("[android-jni] failed to launch the document picker: {error}");
false
}
}
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeInit(
env: jni::JNIEnv,
_class: jni::objects::JClass,
) {
init_logging();
match env.get_java_vm() {
Ok(vm) => {
if JAVA_VM.set(vm).is_ok() {
log::info!("[android-jni] JavaVM stored, native library initialized");
} else {
log::info!("[android-jni] JavaVM already initialized (duplicate call)");
}
}
Err(error) => {
log::error!("[android-jni] failed to get JavaVM: {error}");
}
}
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeAttachContext<'local>(
mut env: jni::JNIEnv<'local>,
_class: jni::objects::JClass<'local>,
context: jni::objects::JObject<'local>,
) -> jni::sys::jint {
if set_activity_context(&mut env, &context) {
1
} else {
0
}
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeOpenDocument<'local>(
mut env: jni::JNIEnv<'local>,
_class: jni::objects::JClass<'local>,
mime_type: jni::objects::JString<'local>,
) -> jni::sys::jint {
let mime: String = match env.get_string(&mime_type) {
Ok(text) => text.into(),
Err(error) => {
log::error!("[android-jni] nativeOpenDocument: unreadable mime type: {error}");
return 0;
}
};
if launch_document_picker(&mut env, &mime) {
1
} else {
0
}
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeIntegrationStatus(
_env: jni::JNIEnv,
_class: jni::objects::JClass,
) -> jni::sys::jint {
let mut status = 0;
if is_initialized() {
status |= 1;
}
if has_activity_context() {
status |= 2;
}
status
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeMethodCount(
_env: jni::JNIEnv,
_class: jni::objects::JClass,
) -> jni::sys::jint {
NATIVE_METHOD_COUNT as jni::sys::jint
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeInstallLogging(
_env: jni::JNIEnv,
_class: jni::objects::JClass,
) {
init_logging();
}
#[no_mangle]
pub extern "system" fn Java_rust_1widgets_RustWidgets_nativeDetachContext(
_env: jni::JNIEnv,
_class: jni::objects::JClass,
) {
let mut slot = activity_context_slot().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
*slot = None;
log::info!("[android-jni] Activity Context detached");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bridge_is_uninitialized_without_a_vm() {
assert!(!is_initialized());
assert!(!has_activity_context());
assert!(!native_view_creation_ready());
assert!(!launch_file_dialog("*/*"));
assert!(with_jni_env(|_| ()).is_none());
}
#[test]
fn integration_status_is_honest_before_init() {
let status = android_integration_ready();
assert!(!status.jni_initialized);
assert!(!status.context_attached);
assert!(!status.ready);
assert_eq!(status.native_methods_count, NATIVE_METHOD_COUNT);
}
#[test]
fn reported_entry_point_count_matches_the_exports() {
let source = include_str!("android_jni.rs");
let exported = source.matches("Java_rust_1widgets_RustWidgets_").count();
assert_eq!(
exported as u32, NATIVE_METHOD_COUNT,
"NATIVE_METHOD_COUNT must be updated when a JNI entry point is added or removed",
);
}
#[test]
fn logcat_priorities_follow_the_android_scale() {
assert_eq!(logcat_priority(log::Level::Error), 6);
assert_eq!(logcat_priority(log::Level::Warn), 5);
assert_eq!(logcat_priority(log::Level::Info), 4);
assert_eq!(logcat_priority(log::Level::Debug), 3);
assert_eq!(logcat_priority(log::Level::Trace), 2);
}
}