Two Java classes (MainActivity, NotificationService) hand their lifecycle to a new android-shell crate built on client-core; client-core gains notifications.rs (the /notifications SSE parse and attention_line, ported from Notifications.kt). Packaged as a new app/shellApp Gradle module rather than a rewrite of app/androidApp in place, so that module's working Compose UI is untouched. Both pass conditions held on the emulator: a notification arrived in Android's drawer with the app closed, and a shared text share landed as a real message in a sandbox session's transcript. Found and fixed three real bugs along the way (a silently-wrong JNI signature from a generic JObject parameter, a class-by-name lookup failing on this crate's own background thread for lack of an app ClassLoader, and onStartCommand opening two /notifications connections per enrollment -- the last a latent bug in Notifications.kt itself). Full account, exact commands and what was deliberately cut are in RUST.md's E3 box. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
184 lines
7.1 KiB
Rust
184 lines
7.1 KiB
Rust
//! 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<Option<String>> {
|
|
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::<JString>(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::<JString>(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<String, String> {
|
|
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)
|
|
}
|