#![allow(unsafe_code)]
use crate::android_haptics_queue::{HapticCommand, HapticQueue};
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::{Arc, 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())));
let haptics_queue = if async_haptics_enabled() {
spawn_haptics_thread(app.clone())
} else {
log::info!("CRANPOSE_ASYNC_HAPTICS=0: haptics stay on the calling thread");
None
};
set_platform_haptics(Rc::new(AndroidHaptics {
app: app.clone(),
amplitude_control: Cell::new(None),
queue: haptics_queue,
}));
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();
}
}
}
}
const HAPTIC_QUEUE_CAPACITY: usize = 8;
fn async_haptics_enabled() -> bool {
match std::env::var("CRANPOSE_ASYNC_HAPTICS") {
Ok(value) => !matches!(value.trim(), "0" | "false" | "off" | "no"),
Err(_) => true,
}
}
struct AndroidHaptics {
app: android_activity::AndroidApp,
amplitude_control: Cell<Option<bool>>,
queue: Option<Arc<HapticQueue>>,
}
impl AndroidHaptics {
fn dispatch(&self, command: HapticCommand) {
let command = match &self.queue {
Some(queue) => match queue.enqueue(command) {
Ok(()) => return,
Err(command) => command,
},
None => command,
};
deliver_haptic_command(&self.app, &command);
}
}
impl Drop for AndroidHaptics {
fn drop(&mut self) {
if let Some(queue) = &self.queue {
queue.shut_down();
}
}
}
fn spawn_haptics_thread(app: android_activity::AndroidApp) -> Option<Arc<HapticQueue>> {
let queue = Arc::new(HapticQueue::new(HAPTIC_QUEUE_CAPACITY));
let worker_queue = Arc::clone(&queue);
match std::thread::Builder::new()
.name("cranpose-haptics".to_owned())
.spawn(move || run_haptics_thread(app, worker_queue))
{
Ok(_) => Some(queue),
Err(error) => {
log::warn!(
"cranpose-haptics thread failed to start; haptics stay synchronous: {error}"
);
None
}
}
}
fn run_haptics_thread(app: android_activity::AndroidApp, queue: Arc<HapticQueue>) {
struct ShutDownOnExit(Arc<HapticQueue>);
impl Drop for ShutDownOnExit {
fn drop(&mut self) {
self.0.shut_down();
}
}
let _guard = ShutDownOnExit(Arc::clone(&queue));
while let Some(command) = queue.dequeue() {
deliver_haptic_command(&app, &command);
queue.note_delivered();
}
log::debug!("cranpose-haptics thread exited after shutdown");
}
fn deliver_haptic_command(app: &android_activity::AndroidApp, command: &HapticCommand) {
let what = match command {
HapticCommand::Perform(_) => "haptic",
HapticCommand::OneShot { .. } => "haptic one-shot",
HapticCommand::Waveform { .. } => "haptic waveform",
HapticCommand::Effect(_) => "haptic effect",
HapticCommand::Cancel => "haptic cancel",
};
let result = with_android_activity_env(app, |env, activity| match command {
HapticCommand::Perform(feedback) => {
let kind: jint = match feedback {
HapticFeedback::ImpactLight | HapticFeedback::Selection => 0,
HapticFeedback::ImpactMedium => 1,
HapticFeedback::ImpactHeavy => 2,
HapticFeedback::Success => 3,
HapticFeedback::Warning | HapticFeedback::Error => 4,
};
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(())
}
HapticCommand::OneShot {
duration_ms,
amplitude,
} => {
let amplitude: jint = if *amplitude == 0 {
-1
} else {
jint::from(*amplitude)
};
env.call_method(
&activity,
jni_str!("cranposeHapticOneShot"),
jni_sig!("(JI)V"),
&[
JValue::Long(jlong::from(*duration_ms)),
JValue::Int(amplitude),
],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
}
HapticCommand::Waveform {
timings_ms,
amplitudes,
repeat,
} => {
let timing_array = env
.new_long_array(timings_ms.len())
.map_err(|error| error.to_string())?;
timing_array
.set_region(env, 0, timings_ms)
.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, amplitudes)
.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(())
}
HapticCommand::Effect(effect) => {
let id: jint = match effect {
HapticEffect::Click => 0,
HapticEffect::DoubleClick => 1,
HapticEffect::Tick => 2,
HapticEffect::HeavyClick => 3,
};
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(())
}
HapticCommand::Cancel => {
env.call_method(
&activity,
jni_str!("cranposeHapticCancel"),
jni_sig!("()V"),
&[],
)
.map_err(|error| {
clear_pending_android_jni_exception(env);
error.to_string()
})?;
Ok(())
}
});
if let Err(error) = result {
log::debug!("Android {what} failed: {error}");
}
}
impl Haptics for AndroidHaptics {
fn perform(&self, feedback: HapticFeedback) {
self.dispatch(HapticCommand::Perform(feedback));
}
fn vibrate(&self, duration_ms: u32, amplitude: u8) {
if duration_ms == 0 {
return;
}
self.dispatch(HapticCommand::OneShot {
duration_ms,
amplitude,
});
}
fn play_pattern(&self, pattern: &HapticPattern) {
self.dispatch(HapticCommand::Waveform {
timings_ms: pattern
.timings_ms()
.iter()
.map(|step| i64::from(*step))
.collect(),
amplitudes: pattern
.amplitudes()
.iter()
.map(|level| i32::from(*level))
.collect(),
repeat: pattern
.repeat()
.and_then(|index| i32::try_from(index).ok())
.unwrap_or(-1),
});
}
fn perform_effect(&self, effect: HapticEffect) {
self.dispatch(HapticCommand::Effect(effect));
}
fn cancel(&self) {
self.dispatch(HapticCommand::Cancel);
}
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();
}