diff --git a/client/src-tauri/src/android_bg.rs b/client/src-tauri/src/android_bg.rs new file mode 100644 index 00000000..69e647b4 --- /dev/null +++ b/client/src-tauri/src/android_bg.rs @@ -0,0 +1,138 @@ +//! Android side of the background watcher (`bg_activity`): drives +//! `com.phantomptr.ps5upload.TransferService`, the foreground service that keeps the process +//! (and with it the in-process engine) alive while a transfer runs with the app in the +//! background, and posts the complete / failed notifications. The Kotlin class is written into +//! `gen/android` by `scripts/release/android-postinit-patch.sh`. +//! +//! Every call is best-effort: a failure is logged and the transfer carries on without the +//! service, as it did before it existed. + +use jni::objects::{GlobalRef, JClass, JObject, JValue}; +use jni::JNIEnv; +use std::sync::OnceLock; + +use crate::android_jni::{app_class, app_context, vm}; + +const SERVICE_CLASS: &str = "com.phantomptr.ps5upload.TransferService"; + +/// The service class, looked up once through the app's class loader. +static CLASS: OnceLock = OnceLock::new(); + +fn with_service( + what: &str, + f: impl for<'a> FnOnce(&mut JNIEnv<'a>, &JClass<'a>, &JObject<'a>) -> jni::errors::Result<()>, +) { + let result = (|| -> Result<(), String> { + let mut env = vm()? + .attach_current_thread() + .map_err(|e| format!("attach: {e}"))?; + let out = (|| { + let context = app_context(&mut env)?; + let global = match CLASS.get() { + Some(g) => g, + None => { + let class = app_class(&mut env, &context, SERVICE_CLASS)?; + let g = env + .new_global_ref(class) + .map_err(|e| format!("global ref: {e}"))?; + CLASS.get_or_init(|| g) + } + }; + let local = env + .new_local_ref(global.as_obj()) + .map_err(|e| format!("local ref: {e}"))?; + let class = JClass::from(local); + f(&mut env, &class, &context).map_err(|e| e.to_string()) + })(); + // Never hand the thread back with a pending exception. + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + out + })(); + if let Err(e) = result { + eprintln!("[bg] TransferService.{what}: {e}"); + } +} + +/// Start the foreground service with its first progress line. `percent` < 0 = indeterminate. +pub fn start(title: &str, text: &str, percent: i32, channel: &str) { + with_service("start", |env, class, ctx| { + let (t, x, c) = ( + env.new_string(title)?, + env.new_string(text)?, + env.new_string(channel)?, + ); + env.call_static_method( + class, + "start", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)V", + &[ + JValue::Object(ctx), + JValue::Object(&t), + JValue::Object(&x), + JValue::Int(percent), + JValue::Object(&c), + ], + )?; + Ok(()) + }); +} + +/// Refresh the ongoing progress notification. +pub fn update(title: &str, text: &str, percent: i32) { + with_service("update", |env, class, ctx| { + let (t, x) = (env.new_string(title)?, env.new_string(text)?); + env.call_static_method( + class, + "update", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;I)V", + &[ + JValue::Object(ctx), + JValue::Object(&t), + JValue::Object(&x), + JValue::Int(percent), + ], + )?; + Ok(()) + }); +} + +/// Post a complete / failed notification on the results channel. `id` keeps one per job. +pub fn finished(id: i32, title: &str, text: &str, ok: bool, channel: &str) { + with_service("finished", |env, class, ctx| { + let (t, x, c) = ( + env.new_string(title)?, + env.new_string(text)?, + env.new_string(channel)?, + ); + env.call_static_method( + class, + "finished", + "(Landroid/content/Context;ILjava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V", + &[ + JValue::Object(ctx), + JValue::Int(id), + JValue::Object(&t), + JValue::Object(&x), + JValue::Bool(ok as u8), + JValue::Object(&c), + ], + )?; + Ok(()) + }); +} + +/// Stop the service and remove its progress notification. +pub fn stop() { + with_service("stop", |env, class, ctx| { + env.call_static_method( + class, + "stop", + "(Landroid/content/Context;)V", + &[JValue::Object(ctx)], + )?; + Ok(()) + }); +} diff --git a/client/src-tauri/src/android_jni.rs b/client/src-tauri/src/android_jni.rs new file mode 100644 index 00000000..bffc4f8d --- /dev/null +++ b/client/src-tauri/src/android_jni.rs @@ -0,0 +1,88 @@ +//! The JavaVM and Application context every Android JNI caller in this crate shares +//! (`commands::local_fs`, `android_bg`). `JNI_OnLoad` may be defined once per library, so it +//! lives here rather than with any one caller. + +use jni::objects::{JClass, JObject, JValue}; +use jni::{JNIEnv, JavaVM}; +use std::ffi::c_void; +use std::sync::OnceLock; + +/// JavaVM captured at native-library load. +/// +/// Tauri 2's Android backend (a Kotlin WryActivity that `loadLibrary`s +/// this `.so`) does NOT initialize the `ndk_context` crate's global — +/// tao carries its own internal glue and never calls +/// `ndk_context::initialize_android_context`. So the old +/// `ndk_context::android_context()` here always hit an uninitialized +/// global and aborted the whole app on first use ("android context was +/// not initialized"). We capture the VM ourselves in `JNI_OnLoad` +/// instead — the runtime calls it once when the library loads, before +/// any Tauri command can run, and neither wry nor tao defines it. +static JVM: OnceLock = OnceLock::new(); + +/// Android runtime entry point, invoked on `System.loadLibrary`. +#[no_mangle] +pub extern "system" fn JNI_OnLoad( + vm: *mut jni::sys::JavaVM, + _reserved: *mut c_void, +) -> jni::sys::jint { + if let Ok(vm) = unsafe { JavaVM::from_raw(vm) } { + let _ = JVM.set(vm); + } + jni::sys::JNI_VERSION_1_6 +} + +pub(crate) fn vm() -> Result<&'static JavaVM, String> { + JVM.get() + .ok_or_else(|| "Android JavaVM unavailable (JNI_OnLoad not run)".to_string()) +} + +/// The app's Application context — a `Context` we can use for +/// `getSystemService` and `startActivity(FLAG_ACTIVITY_NEW_TASK)`. +/// Fetched via `ActivityThread.currentApplication()` so we don't need a +/// captured Activity (or the unavailable `ndk_context`). +pub(crate) fn app_context<'local>(env: &mut JNIEnv<'local>) -> Result, String> { + let app = env + .call_static_method( + "android/app/ActivityThread", + "currentApplication", + "()Landroid/app/Application;", + &[], + ) + .map_err(|e| format!("currentApplication: {e}"))? + .l() + .map_err(|e| format!("application obj: {e}"))?; + if app.is_null() { + return Err("currentApplication() returned null".into()); + } + Ok(app) +} + +/// One of the app's own classes (`com.phantomptr.ps5upload.…`). `FindClass` on a thread Rust +/// attached searches the system class loader, which does not have them; the context's loader +/// does. `name` is dotted (`com.example.Foo`). +pub(crate) fn app_class<'local>( + env: &mut JNIEnv<'local>, + context: &JObject, + name: &str, +) -> Result, String> { + let loader = env + .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) + .map_err(|e| format!("getClassLoader: {e}"))? + .l() + .map_err(|e| format!("loader obj: {e}"))?; + let jname = env + .new_string(name) + .map_err(|e| format!("class name: {e}"))?; + let class = env + .call_method( + &loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&jname)], + ) + .map_err(|e| format!("loadClass {name}: {e}"))? + .l() + .map_err(|e| format!("class obj: {e}"))?; + Ok(JClass::from(class)) +} diff --git a/client/src-tauri/src/bg_activity.rs b/client/src-tauri/src/bg_activity.rs new file mode 100644 index 00000000..79212ce2 --- /dev/null +++ b/client/src-tauri/src/bg_activity.rs @@ -0,0 +1,860 @@ +//! Background watcher (#457): notices from the host, not the WebView, that work is running or +//! has just ended. +//! +//! A hidden window throttles the renderer's timers (WebView2, WebKitGTK, a hidden WKWebView can +//! be suspended) and Android freezes a backgrounded app's process outright, so the renderer is +//! the wrong place to decide "a job finished, tell the person and start the next one". This +//! task polls the engine's `GET /api/activity` every second instead and: +//! +//! - on Android, runs a foreground service (`android_bg`) while anything is active, which keeps +//! the process and the in-process engine alive, with an ongoing progress notification; +//! - posts a complete / failed notification when the app is in the background; +//! - emits `bg-job-finished` (and a `bg-tick` heartbeat while work runs) so the renderer's queue +//! advances without waiting on a throttled timer. +//! +//! "Active" is an engine job running, or a renderer hold (queued items waiting their turn) +//! refreshed within the last 5 minutes; the service stops 30 s after nothing is active. +//! The decisions live in [`step`], which is pure and unit-tested. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock, RwLock}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Runtime}; + +const POLL: Duration = Duration::from_secs(1); +/// A renderer hold counts this long after its last refresh (the renderer refreshes every 60 s): +/// a frozen renderer cannot keep the service up forever. +const HOLD_TTL_MS: u64 = 5 * 60 * 1000; +/// Nothing active for this long stops the service. Covers the gap between queue items. +const IDLE_GRACE_MS: u64 = 30 * 1000; +/// Weight of the newest sample in the speed average. +const EMA_ALPHA: f64 = 0.3; + +// ── Engine snapshot ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct ActiveJob { + pub id: String, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub bytes_done: u64, + #[serde(default)] + pub bytes_total: u64, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct FinishedJob { + pub seq: u64, + pub id: String, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub ok: bool, + #[serde(default)] + pub cancelled: bool, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub bytes: u64, + #[serde(default)] + pub elapsed_ms: u64, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct Snapshot { + pub seq: u64, + #[serde(default)] + pub active: Vec, + #[serde(default)] + pub finished: Vec, +} + +// ── Strings (the renderer sends its language's; English until it does) ────── + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(default)] +pub struct BgStrings { + pub uploading: String, + pub downloading: String, + pub installing: String, + pub working: String, + pub jobs_running: String, + pub waiting: String, + pub progress_of: String, + pub done_upload: String, + pub done_download: String, + pub done_install: String, + pub done_job: String, + pub failed_upload: String, + pub failed_download: String, + pub failed_install: String, + pub failed_job: String, + pub channel_progress: String, + pub channel_results: String, +} + +impl Default for BgStrings { + fn default() -> Self { + let s = |v: &str| v.to_string(); + Self { + uploading: s("Uploading {name}"), + downloading: s("Downloading {name}"), + installing: s("Installing {name}"), + working: s("Working on {name}"), + jobs_running: s("{n} jobs running"), + waiting: s("Waiting for the next item"), + progress_of: s("{done} of {total}"), + done_upload: s("Upload complete"), + done_download: s("Download complete"), + done_install: s("Installed"), + done_job: s("Finished"), + failed_upload: s("Upload failed"), + failed_download: s("Download failed"), + failed_install: s("Install failed"), + failed_job: s("Failed"), + channel_progress: s("Transfers in progress"), + channel_results: s("Finished transfers"), + } + } +} + +// ── The pure core ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub struct Progress { + pub title: String, + pub text: String, + /// `None` when the total is unknown (indeterminate bar). + pub percent: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + StartFg(Progress), + Update(Progress), + Finished(FinishedJob), + StopFg, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Rate { + bytes: u64, + at_ms: u64, + bps: f64, +} + +#[derive(Debug, Default)] +pub struct State { + /// The engine seq already seen; `None` until the first poll. + pub seq: Option, + /// The service (and progress notification) is up. + pub fg: bool, + idle_since_ms: Option, + last: Option, + rates: HashMap, + pub strings: BgStrings, +} + +/// One poll's decisions. The first poll only records `seq`, so a launch never re-announces +/// endings from before it; a `seq` lower than the one seen means the engine restarted, and +/// counting starts again from its value. +pub fn step(state: &mut State, snap: &Snapshot, hold: bool, now_ms: u64) -> Vec { + let mut out = Vec::new(); + match state.seq { + Some(seen) if snap.seq >= seen => { + for f in snap.finished.iter().filter(|f| f.seq > seen) { + out.push(Action::Finished(f.clone())); + } + } + _ => {} + } + state.seq = Some(snap.seq); + + update_rates(&mut state.rates, &snap.active, now_ms); + let active = !snap.active.is_empty() || hold; + if active { + state.idle_since_ms = None; + let p = format_progress(&snap.active, &state.rates, &state.strings); + if !state.fg { + state.fg = true; + out.push(Action::StartFg(p.clone())); + state.last = Some(p); + } else if state.last.as_ref() != Some(&p) { + out.push(Action::Update(p.clone())); + state.last = Some(p); + } + } else if state.fg { + match state.idle_since_ms { + None => { + state.idle_since_ms = Some(now_ms); + // The last job's 99% would otherwise sit there through the grace period. + let p = format_progress(&[], &state.rates, &state.strings); + if state.last.as_ref() != Some(&p) { + out.push(Action::Update(p.clone())); + state.last = Some(p); + } + } + Some(t) if now_ms.saturating_sub(t) >= IDLE_GRACE_MS => { + state.fg = false; + state.idle_since_ms = None; + state.last = None; + out.push(Action::StopFg); + } + Some(_) => {} + } + } + out +} + +/// Per-job speed, an exponential moving average of bytes over time between polls. Jobs that +/// are no longer running are forgotten. +fn update_rates(rates: &mut HashMap, active: &[ActiveJob], now_ms: u64) { + rates.retain(|id, _| active.iter().any(|j| &j.id == id)); + for j in active { + match rates.get_mut(&j.id) { + None => { + rates.insert( + j.id.clone(), + Rate { + bytes: j.bytes_done, + at_ms: now_ms, + bps: 0.0, + }, + ); + } + Some(r) => { + let dt = now_ms.saturating_sub(r.at_ms); + if dt == 0 { + continue; + } + let sample = j.bytes_done.saturating_sub(r.bytes) as f64 * 1000.0 / dt as f64; + r.bps = if r.bps == 0.0 { + sample + } else { + EMA_ALPHA * sample + (1.0 - EMA_ALPHA) * r.bps + }; + r.bytes = j.bytes_done; + r.at_ms = now_ms; + } + } + } +} + +/// `1.2 GB`, `38 MB`: SI units like the engine's MB/s, one decimal below 10. +pub fn fmt_bytes(n: f64) -> String { + const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"]; + let mut v = n.max(0.0); + let mut u = 0; + while v >= 1000.0 && u < UNITS.len() - 1 { + v /= 1000.0; + u += 1; + } + if u == 0 || v >= 10.0 { + format!("{v:.0} {}", UNITS[u]) + } else { + format!("{v:.1} {}", UNITS[u]) + } +} + +/// The progress notification: "Uploading