use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::{JNIEnv, JavaVM};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
static BACK_PENDING: AtomicBool = AtomicBool::new(false);
pub fn take_back_pressed() -> bool {
BACK_PENDING.swap(false, Ordering::SeqCst)
}
#[derive(Debug, Clone, Copy)]
pub enum SystemEvent {
Play,
Pause,
Toggle,
Next,
Prev,
Stop,
}
static JVM: OnceLock<JavaVM> = OnceLock::new();
static CLASSLOADER: OnceLock<GlobalRef> = OnceLock::new();
static BACKGROUND_HANDLER: OnceLock<Arc<Mutex<Option<Box<dyn Fn(SystemEvent) + Send + Sync>>>>> =
OnceLock::new();
fn get_bg_handler() -> Arc<Mutex<Option<Box<dyn Fn(SystemEvent) + Send + Sync>>>> {
BACKGROUND_HANDLER
.get_or_init(|| Arc::new(Mutex::new(None)))
.clone()
}
pub fn set_background_handler(handler: impl Fn(SystemEvent) + Send + Sync + 'static) {
let binding = get_bg_handler();
let mut guard = binding.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(Box::new(handler));
}
fn dispatch_event(event: SystemEvent) {
if let Ok(guard) = get_bg_handler().lock() {
if let Some(ref handler) = *guard {
handler(event);
}
}
}
pub fn init() {
static ONCE: OnceLock<()> = OnceLock::new();
ONCE.get_or_init(|| {
let ctx = ndk_context::android_context();
let vm_ptr = ctx.vm();
if vm_ptr.is_null() {
return;
}
match unsafe { JavaVM::from_raw(vm_ptr.cast()) } {
Ok(vm) => {
let _ = JVM.set(vm);
cache_classloader();
init_media_session();
}
Err(e) => tracing::warn!(error = %e, "failed to capture JVM"),
}
});
}
fn cache_classloader() {
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let ctx = ndk_context::android_context();
let raw = ctx.context();
if raw.is_null() {
tracing::warn!("null activity context; skipping classloader cache");
return;
}
let activity = unsafe { JObject::from_raw(raw.cast()) };
let result: Result<(), jni::errors::Error> = (|| {
let cl = env
.call_method(
&activity,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let global = env.new_global_ref(&cl)?;
let _ = CLASSLOADER.set(global);
Ok(())
})();
if let Err(e) = result {
tracing::warn!(error = %e, "failed to cache classloader");
}
}
fn find_app_class<'a>(env: &mut JNIEnv<'a>, name: &str) -> Result<JClass<'a>, jni::errors::Error> {
if let Some(cl) = CLASSLOADER.get() {
let dot_name = env.new_string(name.replace('/', "."))?;
let class_obj = env
.call_method(
cl.as_obj(),
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&dot_name)],
)?
.l()?;
Ok(JClass::from(class_obj))
} else {
env.find_class(name)
}
}
fn init_media_session() {
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "attach_current_thread failed");
return;
}
};
let ctx = ndk_context::android_context();
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let result: Result<(), jni::errors::Error> = (|| {
let class = find_app_class(&mut env, "com/temidaradev/kopuz/MediaSessionHelper")?;
env.call_static_method(
&class,
"init",
"(Landroid/content/Context;)V",
&[JValue::Object(&activity)],
)?
.v()?;
Ok(())
})();
if let Err(e) = result {
tracing::warn!(error = %e, "MediaSessionHelper.init failed");
clear_jni_exception(&mut env);
}
}
fn dir_via_jni(method: &str) -> Option<String> {
init();
let vm = JVM.get()?;
let mut env = vm.attach_current_thread().ok()?;
let ctx = ndk_context::android_context();
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let r: Result<String, jni::errors::Error> = (|| {
let file = env
.call_method(&activity, method, "()Ljava/io/File;", &[])?
.l()?;
let path = env
.call_method(&file, "getAbsolutePath", "()Ljava/lang/String;", &[])?
.l()?;
Ok(env.get_string(&JString::from(path))?.into())
})();
r.map_err(|_| {
if env.exception_check().unwrap_or(false) {
let _ = env.exception_clear();
}
})
.ok()
}
pub fn get_files_dir() -> Option<String> {
dir_via_jni("getFilesDir").or_else(|| {
std::env::var("FILES_DIR").ok().or_else(|| {
let home = std::env::var("HOME").ok()?;
if home.contains("com.temidaradev.kopuz") {
Some(format!("{}/files", home))
} else {
None
}
})
})
}
pub fn get_android_music_dir() -> Option<String> {
init();
let vm = JVM.get()?;
let mut env = vm.attach_current_thread().ok()?;
let result: Result<String, jni::errors::Error> = (|env: &mut JNIEnv| {
let env_class = env.find_class("android/os/Environment")?;
let dir_type = env.new_string("Music")?;
let file = env
.call_static_method(
env_class,
"getExternalStoragePublicDirectory",
"(Ljava/lang/String;)Ljava/io/File;",
&[JValue::Object(&dir_type)],
)?
.l()?;
let path = env
.call_method(&file, "getAbsolutePath", "()Ljava/lang/String;", &[])?
.l()?;
Ok(env.get_string(&JString::from(path))?.into())
})(&mut env);
if let Err(e) = result {
tracing::warn!(error = %e, "get_android_music_dir failed");
clear_jni_exception(&mut env);
None
} else {
result.ok()
}
}
fn normalize_artwork(url: &str) -> Option<String> {
if url.starts_with("http://") || url.starts_with("https://") {
return Some(url.to_string());
}
let query = url.strip_prefix("artwork://local?")?;
let encoded = query.split('&').find_map(|kv| {
let mut parts = kv.splitn(2, '=');
if parts.next() == Some("p") {
parts.next()
} else {
None
}
})?;
let decoded = percent_decode(encoded);
let path = if decoded.starts_with("/~") {
std::env::var("HOME")
.ok()
.map(|h| decoded.replacen("/~", &h, 1))
.unwrap_or(decoded)
} else if decoded.starts_with('~') {
std::env::var("HOME")
.ok()
.map(|h| decoded.replacen('~', &h, 1))
.unwrap_or(decoded)
} else {
decoded
};
Some(path)
}
static LAST_DATA_ART: Mutex<Option<(u64, String)>> = Mutex::new(None);
fn resolve_artwork(url: &str) -> Option<String> {
let resolved = if url.starts_with("data:") {
data_url_to_file(url)
} else if let Some(stripped) = url.strip_prefix("file://") {
Some(stripped.to_string())
} else if url.starts_with('/') {
Some(url.to_string())
} else {
normalize_artwork(url)
};
tracing::trace!(
input = &url[..url.len().min(48)],
?resolved,
"resolve_artwork"
);
resolved
}
fn data_url_to_file(url: &str) -> Option<String> {
use base64::{Engine as _, engine::general_purpose};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let meta = url.strip_prefix("data:")?;
let comma = meta.find(',')?;
let header = &meta[..comma];
let payload = &meta[comma + 1..];
if !header.contains("base64") {
return None;
}
let ext = if header.contains("image/png") {
"png"
} else if header.contains("image/webp") {
"webp"
} else if header.contains("image/gif") {
"gif"
} else {
"jpg"
};
let mut hasher = DefaultHasher::new();
payload.hash(&mut hasher);
let hash = hasher.finish();
if let Ok(guard) = LAST_DATA_ART.lock() {
if let Some((last_hash, path)) = guard.as_ref() {
if *last_hash == hash && std::path::Path::new(path).exists() {
return Some(path.clone());
}
}
}
let files_dir = get_files_dir()?;
let path = format!("{files_dir}/np_art_{hash}.{ext}");
let bytes = general_purpose::STANDARD.decode(payload).ok()?;
std::fs::write(&path, &bytes).ok()?;
if let Ok(mut guard) = LAST_DATA_ART.lock() {
if let Some((_, old_path)) = guard.as_ref() {
if old_path != &path {
let _ = std::fs::remove_file(old_path);
}
}
*guard = Some((hash, path.clone()));
}
Some(path)
}
fn percent_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.bytes().peekable();
while let Some(b) = chars.next() {
if b == b'%' {
let h1 = chars.next().map(hex_val).unwrap_or(0);
let h2 = chars.next().map(hex_val).unwrap_or(0);
out.push(char::from(h1 << 4 | h2));
} else if b == b'+' {
out.push(' ');
} else {
out.push(char::from(b));
}
}
out
}
fn hex_val(b: u8) -> u8 {
match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => 0,
}
}
pub fn update_now_playing(
title: &str,
artist: &str,
album: &str,
duration: f64,
position: f64,
playing: bool,
artwork_path: Option<&str>,
) {
init();
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let ctx = ndk_context::android_context();
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let duration_ms = (duration * 1000.0) as i64;
let position_ms = (position * 1000.0) as i64;
let resolved_art = artwork_path.and_then(resolve_artwork);
let result: Result<(), jni::errors::Error> = (|| {
let class = find_app_class(&mut env, "com/temidaradev/kopuz/MediaSessionHelper")?;
let j_title = env.new_string(title)?;
let j_artist = env.new_string(artist)?;
let j_album = env.new_string(album)?;
let null_obj = JObject::null();
let j_art_owned;
let j_art: &JObject = if let Some(ref path) = resolved_art {
j_art_owned = env.new_string(path)?;
&*j_art_owned
} else {
&null_obj
};
env.call_static_method(
&class,
"updateNowPlaying",
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZLjava/lang/String;)V",
&[
JValue::Object(&activity),
JValue::Object(&j_title),
JValue::Object(&j_artist),
JValue::Object(&j_album),
JValue::Long(duration_ms),
JValue::Long(position_ms),
JValue::Bool(playing as u8),
JValue::Object(j_art),
],
)?
.v()?;
Ok(())
})();
if let Err(e) = result {
tracing::warn!(error = %e, "MediaSessionHelper.updateNowPlaying failed");
clear_jni_exception(&mut env);
}
}
pub fn wake_run_loop() {
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let result: Result<(), jni::errors::Error> = (|| {
let class = find_app_class(&mut env, "com/temidaradev/kopuz/MediaSessionHelper")?;
env.call_static_method(&class, "wakeMainThread", "()V", &[])?
.v()?;
Ok(())
})();
if let Err(_) = result {
clear_jni_exception(&mut env);
}
}
pub fn stop_session() {
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let ctx = ndk_context::android_context();
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let result: Result<(), jni::errors::Error> = (|| {
let class = find_app_class(&mut env, "com/temidaradev/kopuz/MediaSessionHelper")?;
env.call_static_method(
&class,
"stopSession",
"(Landroid/content/Context;)V",
&[JValue::Object(&activity)],
)?
.v()?;
Ok(())
})();
if let Err(e) = result {
tracing::warn!(error = %e, "MediaSessionHelper.stopSession failed");
clear_jni_exception(&mut env);
}
}
pub fn request_permissions() {
init();
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let ctx = ndk_context::android_context();
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let result: Result<(), jni::errors::Error> = (|env: &mut JNIEnv| {
let class = find_app_class(env, "com/temidaradev/kopuz/MediaSessionHelper")?;
env.call_static_method(
&class,
"requestPermissions",
"(Landroid/app/Activity;)V",
&[JValue::Object(&activity)],
)?
.v()?;
Ok(())
})(&mut env);
if let Err(e) = result {
tracing::warn!(error = %e, "MediaSessionHelper.requestPermissions failed");
clear_jni_exception(&mut env);
}
}
fn clear_jni_exception(env: &mut JNIEnv) {
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
}
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_temidaradev_kopuz_MediaReceiver_nativeOnAction(
mut env: JNIEnv,
_class: JClass,
action: JString,
) {
let action_str: String = match env.get_string(&action) {
Ok(s) => s.into(),
Err(_) => return,
};
match action_str.as_str() {
"play" => dispatch_event(SystemEvent::Play),
"pause" => dispatch_event(SystemEvent::Pause),
"toggle" => dispatch_event(SystemEvent::Toggle),
"next" => dispatch_event(SystemEvent::Next),
"prev" => dispatch_event(SystemEvent::Prev),
"stop" => dispatch_event(SystemEvent::Stop),
"back" => {
BACK_PENDING.store(true, Ordering::SeqCst);
super::back_wake();
}
_ => {}
}
}
pub fn move_task_to_back() {
let vm = match JVM.get() {
Some(v) => v,
None => return,
};
let mut env = match vm.attach_current_thread() {
Ok(e) => e,
Err(_) => return,
};
let result: Result<(), jni::errors::Error> = (|| {
let class = find_app_class(&mut env, "dev/dioxus/main/MainActivity")?;
env.call_static_method(&class, "moveToBack", "()V", &[])?
.v()?;
Ok(())
})();
if let Err(e) = result {
tracing::warn!(error = %e, "MainActivity.moveToBack failed");
clear_jni_exception(&mut env);
}
}