twonly-app/rust/src/native/prepare.rs

151 lines
5.4 KiB
Rust

/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Keeping media preparation alive when the app is not.
//!
//! Everything between the send button and [`crate::native::transfer::schedule`]
//! — transcoding, encryption, and one Signal ratchet step per recipient — runs
//! in this process. Once the transfer is scheduled the OS owns it, but until
//! then a process death strands the send until the next launch. Each platform
//! protects that window differently:
//!
//! * Android runs the preparation inside a `WorkManager` job, which survives
//! the task being swiped away and is re-run after a reboot.
//! * iOS cannot outlive a force quit at all, so the preparation stays in this
//! process under a `UIApplication` background task assertion, which buys the
//! roughly thirty seconds that a backgrounded app is still allowed to run.
/// How this platform is keeping a preparation alive.
pub(crate) enum Preparation {
/// The OS owns a job that runs the preparation itself; the caller must not
/// start one.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
Scheduled,
/// The preparation belongs to this process. The caller runs it and holds
/// the guard for as long as it does.
InProcess(Guard),
}
/// Releases the platform's background execution assertion when dropped.
pub(crate) struct Guard {
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
assertion: Option<u64>,
}
impl Guard {
/// A guard for a platform that offers no assertion to take.
#[cfg_attr(target_os = "ios", allow(dead_code))]
pub(crate) fn unprotected() -> Self {
Self { assertion: None }
}
}
impl Drop for Guard {
fn drop(&mut self) {
#[cfg(target_os = "ios")]
if let Some(assertion) = self.assertion.take() {
ios::end_background_task(assertion);
}
}
}
/// Asks the platform to protect the preparation of `media_id`.
///
/// Never fails: a platform that cannot protect the work still has to prepare
/// it, and an interrupted preparation is picked up by the next maintenance
/// pass. The reason is logged so a missing native hook is visible.
pub(crate) fn begin(media_id: &str) -> Preparation {
#[cfg(target_os = "android")]
{
match android::schedule(media_id) {
Ok(()) => return Preparation::Scheduled,
Err(error) => {
tracing::warn!(media_id, %error, "no WorkManager preparation job; preparing in process");
}
}
}
#[cfg(target_os = "ios")]
{
let _ = media_id;
return Preparation::InProcess(Guard {
assertion: ios::begin_background_task(),
});
}
#[cfg(not(target_os = "ios"))]
{
let _ = media_id;
Preparation::InProcess(Guard::unprotected())
}
}
#[cfg(target_os = "android")]
mod android {
use crate::error::{Result, TwonlyError};
use crate::native::transfer::android::jni_env;
use jni::objects::{JObject, JValue};
pub(super) fn schedule(media_id: &str) -> Result<()> {
let prepare = crate::native::transfer::android::prepare_class()?;
let mut env = jni_env()?;
let media_id = env
.new_string(media_id)
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
let media_id = JObject::from(media_id);
let call = env
.call_static_method(
prepare,
"schedule",
"(Ljava/lang/String;)Z",
&[JValue::Object(&media_id)],
)
.and_then(|value| value.z());
// A pending Java exception would be raised as a fatal error when this
// thread next enters the VM, taking the process with it.
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
if call.map_err(|error| TwonlyError::Generic(error.to_string()))? {
Ok(())
} else {
Err(TwonlyError::Generic(
"Android rejected the media preparation job".into(),
))
}
}
}
#[cfg(target_os = "ios")]
mod ios {
use std::os::raw::c_char;
type BeginBackgroundTask = unsafe extern "C" fn() -> u64;
type EndBackgroundTask = unsafe extern "C" fn(u64);
fn symbol(name: &std::ffi::CStr) -> Option<*mut std::ffi::c_void> {
// The implementation lives in the app executable, so it is resolved at
// runtime rather than being required when the cdylib is linked.
let pointer = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr().cast::<c_char>()) };
(!pointer.is_null()).then_some(pointer)
}
pub(super) fn begin_background_task() -> Option<u64> {
let callback = symbol(c"twonly_begin_background_task")?;
// SAFETY: the Swift @_cdecl declaration has this exact C ABI signature.
let callback: BeginBackgroundTask = unsafe { std::mem::transmute(callback) };
let identifier = unsafe { callback() };
// UIBackgroundTaskInvalid is reported as zero.
(identifier != 0).then_some(identifier)
}
pub(super) fn end_background_task(identifier: u64) {
let Some(callback) = symbol(c"twonly_end_background_task") else {
return;
};
// SAFETY: the Swift @_cdecl declaration has this exact C ABI signature.
let callback: EndBackgroundTask = unsafe { std::mem::transmute(callback) };
unsafe { callback(identifier) };
}
}