//! Where a notification is said, and the foreground service that keeps //! the connection open while the app is closed. Ported from //! `Notifications.kt`'s `NotificationService`, minus the "session on //! screen" / "hand to the app as a banner" branches: those read //! process-wide state that only exists because a screen is drawn to //! register against, and this experiment draws no screen yet (that is //! E4's job, on iris). So every notification here takes the third branch //! Kotlin's `show` already had -- the platform's own drawer -- which is //! also exactly the case E3's pass condition asks for: **a notification //! arrives with the app closed.** use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use client_core::api::UreqTransport; use client_core::notifications::{SessionNotification, follow_notifications}; use jni::Env; use jni::errors::Result; use jni::objects::{JObject, JValue}; use jni::sys::{JNI_TRUE, jint}; use crate::settings::{self, ServerSettings}; const ALERT_CHANNEL: &str = "sessions"; const ONGOING_CHANNEL: &str = "connection"; const ONGOING_ID: i32 = 1; const ALERT_ID: i32 = 2; /// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`. const RECONNECT_DELAY: Duration = Duration::from_millis(5_000); /// Whether the follow-loop thread is already running. **A deviation from /// `Notifications.kt`, found by testing rather than planned**: the Kotlin /// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }` /// on *every* call, with nothing to notice a previous one is still going -- /// and `sync()` calling `startForegroundService` when the service is /// already running is an ordinary Android start, not a restart, so /// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls /// `sync` once itself, then again inside `handle_enrollment` after saving /// the token) hits exactly this path and was observed opening **two** /// concurrent connections to `/notifications` from one process -- caught /// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached /// thread ai-app-notifications` lines for one enrollment. Guarded here /// rather than left to match Kotlin's behaviour exactly, since duplicating /// a live connection is a resource leak with no upside; worth carrying the /// same guard back to `Notifications.kt` separately. static RUNNING: AtomicBool = AtomicBool::new(false); /// Set by `nativeOnDestroy`, checked by the follow loop between /// reconnects. **Known gap, recorded rather than hidden**: unlike /// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here /// can interrupt a `ureq` read already blocked inside one connection -- /// `Transport::stream` hands back a plain `Read` with no cancellation /// handle. So a stop lands at the next reconnect, not mid-read. `/notifications` /// is idle between events (a keep-alive, per `server/src/routes.rs`), so in /// practice this is a bounded wait rather than a hang; closing that gap /// for real means adding a cancellation point to `client_core::Transport`, /// which is a decision affecting every caller of that trait, not just this /// one -- left for whoever next depends on prompt shutdown. static STOPPING: AtomicBool = AtomicBool::new(false); fn static_int(env: &mut Env, class: &str, field: &str) -> Result { crate::jcall::get_static_field(env, class, field, "I")?.i() } fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result> { crate::jcall::call_static_method( env, "androidx/core/app/NotificationManagerCompat", "from", "(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;", &[JValue::Object(context)], )? .l() } fn create_channel( env: &mut Env, manager: &JObject, id: &str, name: &str, importance: i32, ) -> Result<()> { let id_j = crate::jcall::jstr_obj(env, id)?; let builder = crate::jcall::new_object( env, "androidx/core/app/NotificationChannelCompat$Builder", "(Ljava/lang/String;I)V", &[JValue::Object(&id_j), JValue::Int(importance)], )?; let name_j = crate::jcall::jstr_obj(env, name)?; crate::jcall::call_method( env, &builder, "setName", "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;", &[JValue::Object(&name_j)], )?; let channel = crate::jcall::call_method( env, &builder, "build", "()Landroidx/core/app/NotificationChannelCompat;", &[], )? .l()?; crate::jcall::call_method( env, manager, "createNotificationChannel", "(Landroidx/core/app/NotificationChannelCompat;)V", &[JValue::Object(&channel)], )?; Ok(()) } /// Two channels, because they are two different things to be told -- see /// `Notifications.kt`'s `createChannels` for the reasoning; the names and /// importances here are copied from it exactly, since a phone that has /// seen both apps should not learn two different vocabularies for the /// same fact. fn create_channels(env: &mut Env, context: &JObject) -> Result<()> { let manager = notification_manager(env, context)?; let default = static_int( env, "androidx/core/app/NotificationManagerCompat", "IMPORTANCE_DEFAULT", )?; let min = static_int( env, "androidx/core/app/NotificationManagerCompat", "IMPORTANCE_MIN", )?; create_channel( env, &manager, ALERT_CHANNEL, "Sessions needing attention", default, )?; create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?; Ok(()) } fn new_intent_for<'l>( env: &mut Env<'l>, context: &JObject, class_name: &str, ) -> Result> { let target_class = crate::jcall::find_class(env, class_name)?; crate::jcall::new_object( env, "android/content/Intent", "(Landroid/content/Context;Ljava/lang/Class;)V", &[JValue::Object(context), JValue::Object(&target_class)], ) } /// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s /// `sessionIntent`, including building the URI through `Uri.Builder` /// rather than string concatenation, for the same reason: an id needing /// escaping must survive the round trip. fn session_intent<'l>( env: &mut Env<'l>, context: &JObject, session_id: &str, ) -> Result> { let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?; let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?; crate::jcall::call_method( env, &intent, "setAction", "(Ljava/lang/String;)Landroid/content/Intent;", &[JValue::Object(&action_view)], )?; let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?; let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?; crate::jcall::call_method( env, &builder, "scheme", "(Ljava/lang/String;)Landroid/net/Uri$Builder;", &[JValue::Object(&scheme)], )?; let authority = crate::jcall::jstr_obj(env, "session")?; crate::jcall::call_method( env, &builder, "authority", "(Ljava/lang/String;)Landroid/net/Uri$Builder;", &[JValue::Object(&authority)], )?; let path = crate::jcall::jstr_obj(env, session_id)?; crate::jcall::call_method( env, &builder, "appendPath", "(Ljava/lang/String;)Landroid/net/Uri$Builder;", &[JValue::Object(&path)], )?; let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?; crate::jcall::call_method( env, &intent, "setData", "(Landroid/net/Uri;)Landroid/content/Intent;", &[JValue::Object(&uri)], )?; Ok(intent) } fn pending_activity<'l>( env: &mut Env<'l>, context: &JObject, intent: &JObject, ) -> Result> { let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?; let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?; crate::jcall::call_static_method( env, "android/app/PendingIntent", "getActivity", "(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;", &[ JValue::Object(context), JValue::Int(0), JValue::Object(intent), JValue::Int(update_current | immutable), ], )? .l() } fn builder_call<'l>( env: &mut Env<'l>, builder: &JObject<'l>, method: &str, sig: &str, args: &[JValue], ) -> Result<()> { crate::jcall::call_method(env, builder, method, sig, args)?; Ok(()) } /// The type Android 14+ requires a foreground service to declare, and /// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`. fn foreground_type(env: &mut Env) -> Result { let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?; let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?; if sdk >= upside_down_cake { static_int( env, "android/content/pm/ServiceInfo", "FOREGROUND_SERVICE_TYPE_SPECIAL_USE", ) } else { Ok(0) } } fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result> { let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?; let builder = crate::jcall::new_object( env, "androidx/core/app/NotificationCompat$Builder", "(Landroid/content/Context;Ljava/lang/String;)V", &[JValue::Object(context), JValue::Object(&channel)], )?; let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?; builder_call( env, &builder, "setContentTitle", "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Object(&title)], )?; let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?; builder_call( env, &builder, "setSmallIcon", "(I)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Int(icon)], )?; builder_call( env, &builder, "setOngoing", "(Z)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Bool(JNI_TRUE)], )?; let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?; builder_call( env, &builder, "setPriority", "(I)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Int(priority_min)], )?; crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l() } /// Starts the service if there is a server to connect to, and stops it /// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`. pub fn sync(env: &mut Env, context: &JObject) -> Result<()> { let service_intent = new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?; if settings::load(env, context)?.is_none() { crate::jcall::call_method( env, context, "stopService", "(Landroid/content/Intent;)Z", &[JValue::Object(&service_intent)], )?; return Ok(()); } create_channels(env, context)?; crate::jcall::call_static_method( env, "androidx/core/content/ContextCompat", "startForegroundService", "(Landroid/content/Context;Landroid/content/Intent;)V", &[JValue::Object(context), JValue::Object(&service_intent)], )?; Ok(()) } /// The `Service.onStartCommand` body -- loads settings, starts the /// foreground notification, and spawns the follow-loop thread. Answers the /// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the /// framework rather than hardcoded so a wrong guess at their values cannot /// silently pick the other behaviour. pub fn on_start_command(env: &mut Env, service: JObject) -> jint { match try_start(env, &service) { Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1), Ok(false) => { let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]); static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2) } Err(e) => { log_error(env, "onStartCommand", &e); static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2) } } } fn try_start(env: &mut Env, service: &JObject) -> Result { let Some(settings) = settings::load(env, service)? else { return Ok(false); }; let ca = settings::load_pinned_ca(env)?; let notification = ongoing_notification(env, service)?; let fg_type = foreground_type(env)?; crate::jcall::call_static_method( env, "androidx/core/app/ServiceCompat", "startForeground", "(Landroid/app/Service;ILandroid/app/Notification;I)V", &[ JValue::Object(service), JValue::Int(ONGOING_ID), JValue::Object(¬ification), JValue::Int(fg_type), ], )?; // See `RUNNING`'s doc: a second `onStartCommand` while the loop from // the first is still going -- the ordinary case for this service, // since `sync()` is called from more than one place -- must not open // a second connection. if RUNNING.swap(true, Ordering::SeqCst) { return Ok(true); } let vm = env.get_java_vm()?; let context = env.new_global_ref(service)?; STOPPING.store(false, Ordering::SeqCst); std::thread::Builder::new() .name("ai-app-notifications".to_string()) .spawn(move || { // Requests a *permanent* attachment (detached only when this thread // exits), matching the Kotlin original's `thread(isDaemon = true)`: // this is the long-lived follow loop, not a one-shot callback. let _: jni::errors::Result<()> = vm.attach_current_thread(|env| { follow_loop(env, &context, settings, &ca); Ok(()) }); }) .ok(); Ok(true) } /// Follows the backend's notification stream, reconnecting until stopped /// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the /// ordinary case, so it retries quietly and forever; nothing is shown when /// it cannot connect, for the same reason as the Kotlin original: a /// notification saying "I could not tell you whether anything happened" is /// noise about a condition nobody can act on. fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) { while !STOPPING.load(Ordering::SeqCst) { if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) { let _ = follow_notifications(&transport, |notification| { if let Err(e) = show(env, context, ¬ification) { log_error(env, "show", &e); } !STOPPING.load(Ordering::SeqCst) }); } if STOPPING.load(Ordering::SeqCst) { return; } std::thread::sleep(RECONNECT_DELAY); } } /// One notification per session, replacing that session's previous one -- /// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner /// branches this module's doc comment explains. fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> { let manager = notification_manager(env, context)?; let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?; let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?; let allowed = if sdk < tiramisu { true } else { let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?; let granted = static_int( env, "android/content/pm/PackageManager", "PERMISSION_GRANTED", )?; let result = crate::jcall::call_static_method( env, "androidx/core/content/ContextCompat", "checkSelfPermission", "(Landroid/content/Context;Ljava/lang/String;)I", &[JValue::Object(context), JValue::Object(&permission)], )? .i()?; result == granted }; let enabled = crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?; if !allowed || !enabled { return Ok(()); } let intent = session_intent(env, context, ¬ification.session_id)?; let pending = pending_activity(env, context, &intent)?; let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?; let builder = crate::jcall::new_object( env, "androidx/core/app/NotificationCompat$Builder", "(Landroid/content/Context;Ljava/lang/String;)V", &[JValue::Object(context), JValue::Object(&channel)], )?; let title = crate::jcall::jstr_obj(env, ¬ification.title)?; builder_call( env, &builder, "setContentTitle", "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Object(&title)], )?; let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?; builder_call( env, &builder, "setContentText", "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Object(&text)], )?; let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?; builder_call( env, &builder, "setSmallIcon", "(I)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Int(icon)], )?; builder_call( env, &builder, "setContentIntent", "(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Object(&pending)], )?; builder_call( env, &builder, "setAutoCancel", "(Z)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Bool(JNI_TRUE)], )?; let when = (notification.at * 1000.0) as i64; builder_call( env, &builder, "setWhen", "(J)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Long(when)], )?; builder_call( env, &builder, "setShowWhen", "(Z)Landroidx/core/app/NotificationCompat$Builder;", &[JValue::Bool(JNI_TRUE)], )?; let built = crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])? .l()?; let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?; crate::jcall::call_method( env, &manager, "notify", "(Ljava/lang/String;ILandroid/app/Notification;)V", &[ JValue::Object(&tag), JValue::Int(ALERT_ID), JValue::Object(&built), ], )?; Ok(()) } /// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with /// the gap this module's `STOPPING` doc explains. pub fn on_destroy() { STOPPING.store(true, Ordering::SeqCst); // `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own // comment): the old thread may still be inside a blocked read when a // new `onStartCommand` follows immediately, which would spawn a // second one before the first has actually stopped. Narrower than not // resetting at all -- a service destroyed and never restarted would // otherwise wedge `RUNNING` true forever -- and no worse than the // known gap already accepted above. RUNNING.store(false, Ordering::SeqCst); } pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) { let message = format!("android-shell: {where_}: {error}"); let _ = (|| -> Result<()> { let tag = crate::jcall::jstr_obj(env, "android-shell")?; let msg = crate::jcall::jstr_obj(env, &message)?; crate::jcall::call_static_method( env, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", &[JValue::Object(&tag), JValue::Object(&msg)], )?; Ok(()) })(); }