//! Deep links and the share sheet -- ported from `MainActivity.kt`'s //! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`. //! //! **Scope cut, recorded rather than silent**: only shared *text* //! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s //! upload path -- `ContentResolver` reads of a shared file/photo URI, //! bitmap downscaling, EXIF rotation -- is real work of its own and is not //! ported here, because `client-core`'s `ApiClient` does not have the //! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s //! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a //! `content://` stream and no text falls through to a toast saying so, //! rather than silently doing nothing. Closing this gap is the same //! `client-core` work whichever caller needs it next. //! //! **Which session a share lands in** is also a placeholder: with no //! screen drawn yet (E4's job), there is no picker to ask, so this attaches //! to whichever session has the latest `last_activity` -- the one most //! likely to be what somebody meant. Worth revisiting once a real screen //! exists to ask instead of guessing. use client_core::api::{ApiClient, UreqTransport}; use jni::Env; use jni::errors::Result; use jni::objects::{JObject, JString, JValue}; use crate::notify; use crate::settings; const ACTION_SEND: &str = "android.intent.action.SEND"; const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE"; const ACTION_VIEW: &str = "android.intent.action.VIEW"; const EXTRA_TEXT: &str = "android.intent.extra.TEXT"; fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result> { let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?; if value.is_null() { return Ok(None); } let jstr: JString = env.cast_local::(value)?; Ok(Some(jstr.try_to_string(env)?)) } fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> { let message = crate::jcall::jstr_obj(env, message)?; crate::jcall::call_static_method( env, "com/example/aiapp/shell/MainActivity", "toast", "(Landroid/content/Context;Ljava/lang/String;)V", &[JValue::Object(context), JValue::Object(&message)], )?; Ok(()) } /// The one place an incoming intent is sorted into what it means -- mirrors /// `MainActivity.kt`'s `handleIntent`. pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> { let action = get_string_method(env, intent, "getAction")?; if matches!( action.as_deref(), Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE) ) { return handle_share(env, activity, intent); } if action.as_deref() != Some(ACTION_VIEW) { return Ok(()); } let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?; if uri.is_null() { return Ok(()); } let scheme = get_string_method(env, &uri, "getScheme")?; if scheme.as_deref() != Some(settings::SCHEME) { return Ok(()); } match get_string_method(env, &uri, "getHost")?.as_deref() { Some("session") => handle_session_open(env, activity, &uri), Some("enroll") => handle_enrollment(env, activity, &uri), _ => Ok(()), } } fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> { let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else { return Ok(()); }; // There is no session screen yet (E4's job); the toast is this // experiment's stand-in proof that the tap was routed to the right // session id. toast(env, activity, &format!("Opened session {session_id}")) } fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> { match settings::parse_enrollment_uri(env, uri)? { Some(parsed) => { settings::save(env, activity, &parsed)?; notify::sync(env, activity)?; toast( env, activity, &format!("Enrolled with {}", parsed.base_url()), ) } None => toast(env, activity, "Not a valid enrollment code"), } } /// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts /// as a share, and `AttachmentButton`'s upload-then-message pattern for /// what happens to it, minus attachments per this module's doc comment. fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> { let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?; let text = crate::jcall::call_method( env, intent, "getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;", &[JValue::Object(&extra_text)], )? .l()?; let text = if text.is_null() { None } else { let jstr: JString = env.cast_local::(text)?; Some(jstr.try_to_string(env)?) }; let Some(text) = text.filter(|t| !t.trim().is_empty()) else { return toast( env, activity, "Nothing to share -- only shared text is supported so far", ); }; // Network I/O must not run on the calling thread: `handle_intent` is // called from `onCreate`/`onNewIntent`, both on the main thread, and a // blocking socket read there is a `NetworkOnMainThreadException`. So // the actual send happens on a JNI-attached background thread, the // same shape `notify::try_start`'s follow loop uses; `toast` from that // thread is safe because `MainActivity.toast` itself hops back to the // main looper (see that method). let vm = env.get_java_vm()?; let activity_ref = env.new_global_ref(activity)?; std::thread::spawn(move || { let _: jni::errors::Result<()> = vm.attach_current_thread(|env| { share_in_background(env, &activity_ref, text); Ok(()) }); }); Ok(()) } fn share_in_background(env: &mut Env, activity: &JObject, text: String) { let outcome = attach_to_a_session(env, activity, &text); let message = match outcome { Ok(title) => format!("Shared into \"{title}\""), Err(message) => message, }; let _ = toast(env, activity, &message); } fn attach_to_a_session( env: &mut Env, activity: &JObject, text: &str, ) -> std::result::Result { let settings = settings::load(env, activity) .map_err(|e| e.to_string())? .ok_or_else(|| "Not enrolled yet".to_string())?; let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?; let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca) .map_err(|e| e.to_string())?; let client = ApiClient::new(transport); let sessions = client.fetch_sessions().map_err(|e| e.to_string())?; let target = sessions .into_iter() .max_by(|a, b| a.last_activity.total_cmp(&b.last_activity)) .ok_or_else(|| "No session to share into".to_string())?; client .send_message(&target.id, text, &[]) .map_err(|e| e.to_string())?; Ok(target.title) }