#![allow(unsafe_code)]
use crate::android_jni::{clear_pending_android_jni_exception, with_android_activity_env};
use crate::android_launch_args::decode_launch_arguments;
use cranpose_services::{
push_notification_deeplink, set_platform_haptics, set_platform_launch_args,
set_platform_network_monitor, set_platform_notifier, set_platform_share_sheet, HapticEffect,
HapticFeedback, HapticPattern, Haptics, LaunchArgs, NetworkMonitor, NetworkStatus, Notifier,
NotifyRequest, ShareContent, ShareError, ShareSheet,
};
use jni::objects::{JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jint, jlong};
use jni::{jni_sig, jni_str, EnvUnowned, Outcome};
use std::cell::Cell;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Mutex, OnceLock};
static NETWORK_ONLINE: AtomicBool = AtomicBool::new(true);
static NETWORK_METERED: AtomicBool = AtomicBool::new(false);
static INSETS_LEFT_PX: AtomicI32 = AtomicI32::new(0);
static INSETS_TOP_PX: AtomicI32 = AtomicI32::new(0);
static INSETS_RIGHT_PX: AtomicI32 = AtomicI32::new(0);
static INSETS_BOTTOM_PX: AtomicI32 = AtomicI32::new(0);
static INSETS_CHANGED: AtomicBool = AtomicBool::new(false);
static PENDING_LAUNCH_ARGS: Mutex<Option<String>> = Mutex::new(None);
static LOOP_WAKER: OnceLock<Mutex<Option<android_activity::AndroidAppWaker>>> = OnceLock::new();
pub(crate) fn wake_native_loop() {
if let Some(waker) = LOOP_WAKER.get() {
if let Ok(waker) = waker.lock() {
if let Some(waker) = waker.as_ref() {
waker.wake();
}
}
}
}
pub(crate) fn register(app: android_activity::AndroidApp) {
let _ = LOOP_WAKER.set(Mutex::new(Some(app.create_waker())));
set_platform_haptics(Rc::new(AndroidHaptics {
app: app.clone(),
amplitude_control: Cell::new(None),
}));
set_platform_share_sheet(Rc::new(AndroidShareSheet { app: app.clone() }));
set_platform_notifier(Rc::new(AndroidNotifier { app: app.clone() }));
set_platform_network_monitor(Rc::new(AndroidNetworkMonitor));
set_platform_launch_args(Rc::new(read_launch_arguments(&app)));
#[cfg(feature = "playbilling")]
{
crate::android_purchases::register(app.clone());
}
#[cfg(feature = "audio")]
{
cranpose_audio::install();
}
}
fn read_launch_arguments(app: &android_activity::AndroidApp) -> LaunchArgs {
match with_android_activity_env(app, |env, activity| {
let payload = env
.call_method(
&activity,
jni_str!("cranposeEncodeLaunchArguments"),
jni_sig!("()Ljava/lang/String;"),
&[],
)
.and_then(|value| value.l())
.map_err(|error| {
clear_pending_android_jni_exception(env);
format!("failed to read the Android launch arguments: {error}")
})?;
if payload.is_null() {
return Ok(String::new());
}
JString::cast_local(env, payload)
.and_then(|payload| payload.try_to_string(env))
.map_err(|error| {
clear_pending_android_jni_exception(env);
format!("failed to decode the Android launch arguments: {error}")
})
}) {
Ok(payload) => decode_launch_arguments(&payload),
Err(error) => {
log::warn!("Android launch arguments unavailable: {error}");
LaunchArgs::default()
}
}
}
pub(crate) fn apply_pending_platform_signals(
density: f32,
shell: &mut Option<cranpose_app_shell::AppShell<cranpose_render_wgpu::WgpuRenderer>>,
) {
if let Some(payload) = PENDING_LAUNCH_ARGS
.lock()
.ok()
.and_then(|mut slot| slot.take())
{
set_platform_launch_args(Rc::new(decode_launch_arguments(&payload)));
if let Some(shell) = shell {
shell.request_root_render();
}
}
if INSETS_CHANGED.swap(false, Ordering::AcqRel) {
let density = density.max(f32::EPSILON);
let insets = cranpose_ui::EdgeInsets {
left: INSETS_LEFT_PX.load(Ordering::Acquire) as f32 / density,
top: INSETS_TOP_PX.load(Ordering::Acquire) as f32 / density,
right: INSETS_RIGHT_PX.load(Ordering::Acquire) as f32 / density,
bottom: INSETS_BOTTOM_PX.load(Ordering::Acquire) as f32 / density,
};
if crate::android::android_platform_env().set_safe_area(insets) {
if let Some(shell) = shell {
shell.request_root_render();
}
}
}
}
struct AndroidHaptics {
app: android_activity::AndroidApp,
amplitude_control: Cell<Option<bool>>,
}
impl AndroidHaptics {
fn call(
&self,
what: &'static str,
run: impl FnOnce(&mut jni::Env<'_>, JObject<'_>) -> Result<(), String>,
) {
if let Err(error) = with_android_activity_env(&self.app, run) {
log::debug!("Android {what} failed: {error}");
}
}
}
impl Haptics for AndroidHaptics {
fn perform(&self, feedback: HapticFeedback) {
let kind: jint = match feedback {
HapticFeedback::ImpactLight | HapticFeedback::Selection => 0,
HapticFeedback::ImpactMedium => 1,
HapticFeedback::ImpactHeavy => 2,
HapticFeedback::Success => 3,
HapticFeedback::Warning | HapticFeedback::Error => 4,
};
self.call("haptic", |env, activity| {
env.call_method(
&activity,
jni_str!("cranposeHaptic"),
jni_sig!("(I)V"),
&[JValue::Int(kind)],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn vibrate(&self, duration_ms: u32, amplitude: u8) {
if duration_ms == 0 {
return;
}
let amplitude: jint = if amplitude == 0 {
-1
} else {
jint::from(amplitude)
};
let duration = jlong::from(duration_ms);
self.call("haptic one-shot", move |env, activity| {
env.call_method(
&activity,
jni_str!("cranposeHapticOneShot"),
jni_sig!("(JI)V"),
&[JValue::Long(duration), JValue::Int(amplitude)],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn play_pattern(&self, pattern: &HapticPattern) {
let timings: Vec<jlong> = pattern
.timings_ms()
.iter()
.map(|step| jlong::from(*step))
.collect();
let amplitudes: Vec<jint> = pattern
.amplitudes()
.iter()
.map(|level| jint::from(*level))
.collect();
let repeat: jint = pattern
.repeat()
.and_then(|index| jint::try_from(index).ok())
.unwrap_or(-1);
self.call("haptic waveform", move |env, activity| {
let timing_array = env
.new_long_array(timings.len())
.map_err(|error| error.to_string())?;
timing_array
.set_region(env, 0, &timings)
.map_err(|error| error.to_string())?;
let amplitude_array = env
.new_int_array(amplitudes.len())
.map_err(|error| error.to_string())?;
amplitude_array
.set_region(env, 0, &litudes)
.map_err(|error| error.to_string())?;
let timing_obj: &JObject = timing_array.as_ref();
let amplitude_obj: &JObject = amplitude_array.as_ref();
env.call_method(
&activity,
jni_str!("cranposeHapticWaveform"),
jni_sig!("([J[II)V"),
&[
JValue::Object(timing_obj),
JValue::Object(amplitude_obj),
JValue::Int(repeat),
],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn perform_effect(&self, effect: HapticEffect) {
let id: jint = match effect {
HapticEffect::Click => 0,
HapticEffect::DoubleClick => 1,
HapticEffect::Tick => 2,
HapticEffect::HeavyClick => 3,
};
self.call("haptic effect", move |env, activity| {
env.call_method(
&activity,
jni_str!("cranposeHapticPredefined"),
jni_sig!("(I)V"),
&[JValue::Int(id)],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn cancel(&self) {
self.call("haptic cancel", |env, activity| {
env.call_method(
&activity,
jni_str!("cranposeHapticCancel"),
jni_sig!("()V"),
&[],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn has_amplitude_control(&self) -> bool {
if let Some(known) = self.amplitude_control.get() {
return known;
}
let supported = with_android_activity_env(&self.app, |env, activity| {
env.call_method(
&activity,
jni_str!("cranposeHapticHasAmplitudeControl"),
jni_sig!("()Z"),
&[],
)
.and_then(|value| value.z())
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})
})
.unwrap_or(false);
self.amplitude_control.set(Some(supported));
supported
}
}
struct AndroidShareSheet {
app: android_activity::AndroidApp,
}
impl ShareSheet for AndroidShareSheet {
fn share(&self, content: ShareContent) -> Result<(), ShareError> {
with_android_activity_env(&self.app, |env, activity| {
let name = env
.new_string(&content.file_name)
.map_err(|error| error.to_string())?;
let mime = env
.new_string(&content.mime_type)
.map_err(|error| error.to_string())?;
let bytes = env
.byte_array_from_slice(&content.bytes)
.map_err(|error| error.to_string())?;
let text = env
.new_string(content.text.as_deref().unwrap_or(""))
.map_err(|error| error.to_string())?;
let name_obj: &JObject = name.as_ref();
let mime_obj: &JObject = mime.as_ref();
let bytes_obj: &JObject = bytes.as_ref();
let text_obj: &JObject = text.as_ref();
env.call_method(
&activity,
jni_str!("cranposeShare"),
jni_sig!("(Ljava/lang/String;Ljava/lang/String;[BLjava/lang/String;)V"),
&[
JValue::Object(name_obj),
JValue::Object(mime_obj),
JValue::Object(bytes_obj),
JValue::Object(text_obj),
],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
})
.map_err(ShareError::Failed)
}
fn is_supported(&self) -> bool {
true
}
}
struct AndroidNotifier {
app: android_activity::AndroidApp,
}
impl AndroidNotifier {
fn call(&self, run: impl FnOnce(&mut jni::Env<'_>, JObject<'_>) -> Result<(), String>) {
let result = with_android_activity_env(&self.app, |env, activity| run(env, activity));
if let Err(error) = result {
log::warn!("Android notifier call failed: {error}");
}
}
}
impl Notifier for AndroidNotifier {
fn request_permission(&self) {
self.call(|env, activity| {
env.call_method(
&activity,
jni_str!("cranposeNotifyRequestPermission"),
jni_sig!("()V"),
&[],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn notify(&self, request: NotifyRequest) {
self.call(|env, activity| {
let tag = env
.new_string(&request.id)
.map_err(|error| error.to_string())?;
let title = env
.new_string(&request.title)
.map_err(|error| error.to_string())?;
let body = env
.new_string(&request.body)
.map_err(|error| error.to_string())?;
let deeplink = env
.new_string(request.deeplink.as_deref().unwrap_or(""))
.map_err(|error| error.to_string())?;
let tag_obj: &JObject = tag.as_ref();
let title_obj: &JObject = title.as_ref();
let body_obj: &JObject = body.as_ref();
let deeplink_obj: &JObject = deeplink.as_ref();
env.call_method(
&activity,
jni_str!("cranposeNotify"),
jni_sig!(
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V"
),
&[
JValue::Object(tag_obj),
JValue::Object(title_obj),
JValue::Object(body_obj),
JValue::Bool(request.ongoing),
JValue::Object(deeplink_obj),
],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
fn cancel(&self, id: &str) {
self.call(|env, activity| {
let tag = env.new_string(id).map_err(|error| error.to_string())?;
let tag_obj: &JObject = tag.as_ref();
env.call_method(
&activity,
jni_str!("cranposeNotifyCancel"),
jni_sig!("(Ljava/lang/String;)V"),
&[JValue::Object(tag_obj)],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
}
}
struct AndroidNetworkMonitor;
impl NetworkMonitor for AndroidNetworkMonitor {
fn status(&self) -> NetworkStatus {
NetworkStatus {
online: NETWORK_ONLINE.load(Ordering::Acquire),
metered: NETWORK_METERED.load(Ordering::Acquire),
}
}
}
pub(crate) struct AndroidClipboard {
pub(crate) app: android_activity::AndroidApp,
}
impl cranpose_ui::clipboard_session::PlatformClipboard for AndroidClipboard {
fn write_text(&self, text: &str) {
let result = with_android_activity_env(&self.app, |env, activity| {
let value = env.new_string(text).map_err(|error| error.to_string())?;
let value_obj: &JObject = value.as_ref();
env.call_method(
&activity,
jni_str!("cranposeClipboardSet"),
jni_sig!("(Ljava/lang/String;)V"),
&[JValue::Object(value_obj)],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
});
if let Err(error) = result {
log::warn!("Android clipboard write failed: {error}");
}
}
fn read_text(&self) -> Option<String> {
with_android_activity_env(&self.app, |env, activity| {
let value = env
.call_method(
&activity,
jni_str!("cranposeClipboardGet"),
jni_sig!("()Ljava/lang/String;"),
&[],
)
.and_then(|value| value.l())
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
let value = env
.cast_local::<JString>(value)
.map_err(|error| error.to_string())?;
value.try_to_string(env).map_err(|error| error.to_string())
})
.ok()
.filter(|text| !text.is_empty())
}
}
#[doc(hidden)]
#[no_mangle]
pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeOnNetworkStatus(
_env: EnvUnowned<'_>,
_class: JClass<'_>,
online: jboolean,
metered: jboolean,
) {
NETWORK_ONLINE.store(online, Ordering::Release);
NETWORK_METERED.store(metered, Ordering::Release);
}
#[doc(hidden)]
#[no_mangle]
pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeOnInsetsChanged(
_env: EnvUnowned<'_>,
_class: JClass<'_>,
left: jint,
top: jint,
right: jint,
bottom: jint,
) {
INSETS_LEFT_PX.store(left, Ordering::Release);
INSETS_TOP_PX.store(top, Ordering::Release);
INSETS_RIGHT_PX.store(right, Ordering::Release);
INSETS_BOTTOM_PX.store(bottom, Ordering::Release);
INSETS_CHANGED.store(true, Ordering::Release);
wake_native_loop();
}
#[doc(hidden)]
#[no_mangle]
pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeNotificationAction<
'local,
>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
deeplink: JString<'local>,
) {
let deeplink = match env
.with_env(|env| -> jni::errors::Result<String> { deeplink.try_to_string(env) })
.into_outcome()
{
Outcome::Ok(deeplink) => deeplink,
Outcome::Err(_) | Outcome::Panic(_) => return,
};
if !deeplink.is_empty() {
push_notification_deeplink(deeplink);
wake_native_loop();
}
}
#[doc(hidden)]
#[no_mangle]
pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeOnLaunchArguments<
'local,
>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
payload: JString<'local>,
) {
let payload = match env
.with_env(|env| -> jni::errors::Result<String> { payload.try_to_string(env) })
.into_outcome()
{
Outcome::Ok(payload) => payload,
Outcome::Err(_) | Outcome::Panic(_) => return,
};
if let Ok(mut slot) = PENDING_LAUNCH_ARGS.lock() {
*slot = Some(payload);
}
wake_native_loop();
}
#[doc(hidden)]
#[no_mangle]
pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeOnFileSaved<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
token: jlong,
ok: jboolean,
error: JString<'local>,
) {
let error = match env
.with_env(|env| -> jni::errors::Result<String> { error.try_to_string(env) })
.into_outcome()
{
Outcome::Ok(error) if !error.is_empty() => Some(error),
_ => None,
};
crate::android_file_picker::resolve_pending_save(token, ok, error);
wake_native_loop();
}