From c9b273ff16ff08374e6c9d53264b6bbec710b314 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 06:47:32 -0400 Subject: [PATCH] E3: the Kotlin/Java shell over a JNI bridge into Rust (RUST.md) 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 --- .gitignore | 6 + RUST.md | 276 ++++- android-shell/Cargo.lock | 1081 +++++++++++++++++ android-shell/Cargo.toml | 36 + android-shell/src/jcall.rs | 152 +++ android-shell/src/lib.rs | 131 ++ android-shell/src/notify.rs | 556 +++++++++ android-shell/src/settings.rs | 141 +++ android-shell/src/share.rs | 183 +++ app/settings.gradle.kts | 5 + app/shellApp/build.gradle.kts | 115 ++ app/shellApp/src/main/AndroidManifest.xml | 62 + .../com/example/aiapp/shell/MainActivity.java | 50 + .../aiapp/shell/NotificationService.java | 45 + client-core/src/lib.rs | 1 + client-core/src/notifications.rs | 162 +++ 16 files changed, 2996 insertions(+), 6 deletions(-) create mode 100644 android-shell/Cargo.lock create mode 100644 android-shell/Cargo.toml create mode 100644 android-shell/src/jcall.rs create mode 100644 android-shell/src/lib.rs create mode 100644 android-shell/src/notify.rs create mode 100644 android-shell/src/settings.rs create mode 100644 android-shell/src/share.rs create mode 100644 app/shellApp/build.gradle.kts create mode 100644 app/shellApp/src/main/AndroidManifest.xml create mode 100644 app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java create mode 100644 app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java create mode 100644 client-core/src/notifications.rs diff --git a/.gitignore b/.gitignore index 13effa4..94c45b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .gradle/ build/ app/androidApp/build/ +app/shellApp/build/ local.properties .kotlin/ *.iml @@ -9,6 +10,11 @@ local.properties server/target/ event-model/target/ client-core/target/ +android-shell/target/ + +# E3's native library, built by cargo-ndk straight into the Gradle module +# (RUST.md) -- an artifact, like server/target/ above, not source. +app/shellApp/src/main/jniLibs/ # Server logs from a development run (ai-server.log by convention, # wg-test.log from ./test-wg-tunnel.sh). diff --git a/RUST.md b/RUST.md index ee55e96..8771dc3 100644 --- a/RUST.md +++ b/RUST.md @@ -39,8 +39,28 @@ session spending an afternoon on them again. - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the keyboard gap — now explained, see below), E2 (a transcript in Masonry, which found that Masonry has no touch-scroll on Android at all — see - below), I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley + - glyph atlas). + below), **E3 (the Kotlin/Java shell over a JNI bridge into Rust, both + pass conditions proved on the emulator — see its own box)**, I0a, I0b + (iris builds on a pinned nightly and runs), I1 (parley + glyph atlas), + I2 (iris on android-view). +- **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo** + (`android-shell/` — a JNI-bridge crate on `client-core` — plus a new + Gradle module `app/shellApp/`, left deliberately separate from + `app/androidApp` so its ~13,000 lines of working Compose UI are + untouched). Both pass conditions held: a notification arrived in + Android's drawer while the app was closed, and a shared text share + landed as a real `userMessage` in a sandbox session's transcript. Found + and fixed three real bugs along the way — a generic `JObject` native + parameter silently exporting the wrong JNI signature + (`UnsatisfiedLinkError`), a class-by-name lookup failing from this + crate's own background thread because a Rust-attached thread has no app + `ClassLoader` (`Error::NoClassDefFound`, invisible without a logger + installed), and `onStartCommand` opening two `/notifications` + connections per enrollment — the last one a latent bug in + `Notifications.kt` itself, found here rather than there. See E3's own + box for the full account, the exact commands, and what was deliberately + cut (attachment uploads, a session picker, the on-screen/banner + suppression — all pending E4's screen). - **E2 done, 2026-09-05, and its headline finding changes what "decide from the measurements" (recommendation item 3) can mean right now.** Built a real transcript screen (`~/src/android-view/e2-transcript`, @@ -969,10 +989,236 @@ accepted. upstream commit does not yet have for this exact case. That is a point in iris's favour that a frame-time number would not have shown any more clearly. -- [ ] **E3 — the shell.** Kotlin `MainActivity` + `NotificationService` - + Keystore + share intent calling into Rust over JNI, with the SSE - follow loop in Rust. Pass: a notification arrives with the app - closed, and a share lands in a session. +- [x] **E3 — the shell (2026-09-05).** Both pass-condition proofs held on + the emulator: a notification arrived while the app was closed, and a + shared text share landed as a real message in a session's transcript. + Committed to this repo (unlike E1/E2's external, uncommitted trees), + since this is lightweight glue rather than a multi-gigabyte native + build. + + *Where it lives.* `android-shell/` (new crate, `client-core` as its + only real dependency) is the JNI bridge; `app/shellApp/` is a **new + Gradle module**, not a rewrite of `app/androidApp` in place -- + that module is ~13,000 lines of working Compose UI this experiment + does not touch or risk, and the two install side by side on one + development device. `app/shellApp`'s manifest, channel names, + notification wording and share intent-filter are copied from + `androidApp`'s (`Notifications.kt`, `Share.kt`, the manifest) per + AGENTS.md's "reuse rather than re-derive" -- see each file's own doc + comment for exactly what was carried over. Two deliberate + differences, both practical rather than behavioural: application id + `com.example.aiapp.shell` and deep-link scheme `aiappshell` (not + `aiapp`), so this experiment's install cannot collide with the real + app's enrollment or Keystore alias on the same phone -- see + `android-shell/src/settings.rs`'s `SCHEME` doc. + + *The Java floor, and one line more than planned.* Two classes, + matching "How much Java is unavoidable" almost exactly: + `MainActivity.java` (`onCreate`/`onNewIntent` forward to + `nativeHandleIntent`) and `NotificationService.java` + (`onStartCommand`/`onDestroy`/a `sync()` companion, three natives). + Both ~30 lines including the license-free boilerplate Java itself + demands (imports, `System.loadLibrary`). **One addition the analysis + did not anticipate**: `MainActivity.toast(Context, String)`, a + plain (non-native) static method Rust *calls* rather than + implements, because posting a `Toast` from `share.rs`'s background + thread needs a hop back to the main looper + (`new Handler(Looper.getMainLooper()).post(...)`), and JNI can call + an existing Java method on any thread but cannot construct a Java + `Runnable` to hand to `Handler.post`/`runOnUiThread` without a + reflection proxy uglier than three lines of Java. Recorded here + because "the floor is two classes of ten lines" undersold this by + exactly one small, call-only method -- the pattern (Rust calls + Java, never Rust implements a Java interface) is worth keeping the + next time this floor is estimated. + + *What client-core gained.* `notifications.rs`: `SessionNotification`, + `NotificationKind` (mirroring `server/src/session/mod.rs`'s wire + shape field-for-field) and `follow_notifications`, the SSE parse + over `/notifications` built on the same `sse::SseReader` and + `Transport` trait `event_stream.rs` already uses. `attention_line` + is ported verbatim from `Notifications.kt`. 3 new tests (88 total in + the crate); `android-shell` itself has none, since every function in + it needs a live `Env` and there is no pure logic left to test in + isolation once client-core owns the parsing -- matches E2's + precedent ("a throwaway screen, not a library"). + + *Scope cuts, each recorded at its own point in the code rather than + only here:* + - **Text-only share.** `Intent.EXTRA_TEXT` becomes a session message; + a shared file/photo URI is not uploaded, because `client-core`'s + `ApiClient` has no `/sessions/{id}/attachments` route yet either + (`CLIENT_CORE.md`'s own "not covered" list) -- porting + `Attachments.kt`'s `ContentResolver` reads and bitmap downscaling + is real work belonging to whichever caller needs it next, not a + detour inside this box. + - **No session picker.** With no screen drawn yet (E4's job), a + share attaches to whichever session has the latest + `last_activity` -- documented as a placeholder in `share.rs`, + not a designed behaviour. + - **No banner/on-screen suppression.** `notify::show` skips + `Notifications.kt`'s "nothing if this session is on screen" / + "hand to the app as a banner" branches entirely: both read + process-wide state that only means something once a screen + exists to register against it, so every notification here takes + the platform-drawer branch -- which is also exactly what the pass + condition asks for. Revisit once E4 draws something. + - **Keystore is not reimplemented in Rust.** `settings.rs` calls + `wg-app-link`'s existing `ServerStore`/`ServerSettings` Kotlin + classes over JNI rather than re-deriving the AES-GCM sealing: + that code is shared with Dev Updater, already tested, and tied to + a Keystore alias an existing enrolled phone depends on. This does + mean `kotlinc` stays in the toolchain regardless of what E5 does + with `javac`/`d8` for this module's own two classes -- a + correction to "Can the APK be built without Gradle?"'s assumption + that dropping Kotlin drops `kotlinc` outright; it drops it for + *this app's own code*, not for a shared submodule pulled in as a + dependency. + + *`jni` 0.22, not the older API most examples assume.* This is a + real API split (`Env` for real work, `EnvUnowned` as the FFI-safe + type a native fn receives, joined by `EnvUnowned::with_env`), and + the `native_method!` macro (used for all four natives here, via + `const _: NativeMethod = native_method! { ... }`) generates both the + mangled `Java_...` export and the panic/error-handling wrapper from + one Rust function signature -- chosen over hand-written + `#[unsafe(no_mangle)] extern "system" fn Java_com_..._method` because + a hand-typed export name and a hand-typed JNI signature string + routinely drift from the Java they claim to match, silently (see + the next two findings, both of which were exactly that drift). + `error_policy = LogErrorAndDefault` reports a failure to logcat + rather than throwing it back into Java as an exception that would + crash the app over something recoverable -- matching + `Notifications.kt`'s own "log, don't crash" posture, but it is a + no-op without a logger backend (`android_logger`, Android-only + dependency, `lib.rs`'s `ensure_logger`) installed; the class of bug + this exists to report was found once with no logger and read as + nothing having gone wrong at all. + + **Three real findings, each cost a failed run before being + diagnosed, each written where the fix lives so a reader who touches + that file again does not lose an afternoon to it:** + + 1. **A generic `JObject` parameter type silently exports the wrong + JNI signature.** `native_method!`'s shorthand + `fn native_sync(context: JObject) -> ()` encodes the export as + `(Ljava/lang/Object;)V`, because it has no way to know the + intended Java type is `android.content.Context` from a bare + `JObject`. The real Java method is declared + `(Landroid/content/Context;)V`; the two mangled names never + resolve to each other, and the failure is + `UnsatisfiedLinkError: No implementation found`, thrown the + moment Java calls it -- not a build error on either side. Fixed + by spelling each parameter as its actual Java type in the macro + invocation (`context: android.content.Context`, `activity: + android.app.Activity`, ...), which the macro accepts directly + per its "Java Object Types" syntax, while the Rust implementation + function keeps the parameter as plain `JObject` (the "Built-in + Types" fallback for a Java class with no dedicated Rust + wrapper). `lib.rs`'s comment beside the first `native_method!` + call is the citation. + 2. **A class looked up by name from this crate's own background + thread fails, and only for app classes.** `android-shell`'s + follow-loop and share threads are Rust-spawned and attached via + `JavaVM::attach_current_thread`, which the platform never handed + an app `ClassLoader` -- so `FindClass`'s default fallback (used + internally by `find_class`/`new_object`/`call_static_method`/ + `get_static_field`, anything that resolves a class *by name* + rather than from an object it already holds) only reaches the + bootstrap loader's framework classes. `androidx.core.app. + NotificationManagerCompat`, packaged inside this app's own APK, + is invisible from there: `Error::NoClassDefFound`, logged by + `notify::show`'s `LogErrorAndDefault` as "failed to resolve Java + class ... (class not found or linkage error)" -- which on a real + device is indistinguishable from "the notification silently + never arrives," since the *ongoing* foreground notification + (built on the main thread, before this thread exists) posts + fine regardless, so nothing else looks wrong. Fixed in + `jcall.rs`: `remember_class_loader` caches the app's own + `ClassLoader` (`context.getClass().getClassLoader()`) the first + time any entry point with a `Context` runs, and every + class-by-name lookup goes through `LoaderContext::Loader` + explicitly rather than the thread-dependent default -- correct + on the main thread and this crate's background threads alike. + `jcall.rs`'s module doc has the full account. + 3. **`onStartCommand` spawning a thread unconditionally opens a + second connection, and `Notifications.kt` has the same bug.** + Enrolling calls `sync()` twice in one launch (once + unconditionally in `MainActivity.onCreate`, again inside + `handle_enrollment` after saving the token), each of which starts + the service, and Android runs `onStartCommand` once per start + request -- so the follow-loop thread was spawned twice, caught on + `adb logcat` as two `jni::vm::java_vm: Attached thread + ai-app-notifications` lines for one enrollment. Kotlin's + `onStartCommand` has the identical shape (`thread(isDaemon = + true) { follow(settings) }`, no guard), so this is a latent bug + in the reference implementation this port found by testing + rather than something E3 introduced -- worth carrying the same + guard back to `Notifications.kt` separately, not done here. + Fixed in `notify.rs` with a `RUNNING` `AtomicBool`, `swap`ped + true before spawning and reset in `on_destroy`; see its doc + comment for the accepted race this shares with the pre-existing + `STOPPING` gap below. + + **Known gap, not fixed, written where it will be found.** + `notify.rs`'s `STOPPING` flag (checked between reconnects) cannot + interrupt a `ureq` read already blocked inside one connection -- + unlike `HttpURLConnection.disconnect()`, `client_core::Transport` + exposes no cancellation handle. `/notifications` is idle between + events (a keep-alive), so in practice a stop is a bounded wait + rather than a hang; closing this for real means adding a + cancellation point to the `Transport` trait itself, a decision + affecting every caller, not an `android-shell`-only fix. + + *Verification, exact commands.* `cargo fmt -- --check`, + `cargo clippy --all-targets` (zero warnings) and `cargo build` + clean for both `client-core` and `android-shell` on the host + target; `cargo ndk -t x86_64 -P 26 clippy --all-targets` clean for + `android-shell` on the Android target too (the `android_logger` + dependency is Android-only, so this is the only way to compile-check + it). `./run-tests.sh` from the repo root: 127 `server` tests, 88 + `client-core` tests (85 + the 3 new to `notifications.rs`), all + passing -- the port added no regression to what already worked. + `./gradlew :shellApp:lintDebug`: `No issues found` (the report at + `app/shellApp/build/reports/lint-results-debug.txt`). + + *The two pass-condition proofs*, both on this checkout's own AVD + (`ai-app-2`, GPU host per the default, torn down with `emu down` + when this session finished) against `app/ui-sandbox.sh`: + + - **Notification with the app closed.** Enrolled via + `adb shell "am start -a android.intent.action.VIEW -d + 'aiappshell://enroll?host=10.0.2.2&port=&token='"` + (per the sandbox's own banner, substituting the scheme), granted + `POST_NOTIFICATIONS`, pressed home, then + `./ui-sandbox.sh spawn e3notif2` and + `./ui-sandbox.sh send "/question Should I proceed with the deploy?"`. + `adb shell dumpsys notification --noredact` shows a + `channel=sessions` record, `android.title=e3notif2`, + `android.text=Waiting for you` (matching `attention_line` and the + session's own title, exactly what `Notifications.kt` would have + shown) -- posted while the app held no visible activity. Tapping + it (`ui-trace record --do "tap 'e3notif2'"`, found in the + expanded shade after `adb shell cmd statusbar + expand-notifications`) launched + `com.example.aiapp.shell/.MainActivity` with + `dat=aiappshell://session/...`, confirmed in `adb logcat`'s + `ActivityTaskManager: START` line -- the `PendingIntent` names + the right session. + - **A share lands in a session.** With the app enrolled and a + session already active, + `adb shell "am start -a android.intent.action.SEND -t text/plain + --es android.intent.extra.TEXT 'Please check the deploy logs for + errors.' -n com.example.aiapp.shell/.MainActivity"` (the classic + `adb shell` quoting trap from `this-machine-android` applies here + too: the whole `am start` invocation has to be one single-quoted + string handed to the *remote* shell, or the extra's spaces get + re-split away). `./ui-sandbox.sh api + '/sessions//transcript?limit=20'` shows + `{"type":"userMessage","text":"Please check the deploy logs for + errors."}` followed by the echo driver's reply -- the share + reached the most-recently-active session as a real message, not + a mock. - [ ] **E4 — the same screen on the desktop** in a winit window, from the same crate, with only the layout differing. - [ ] **E5 — the packaging xtask**: `cargo ndk` → `javac`/`d8` → `aapt2` @@ -980,6 +1226,24 @@ accepted. installed through Dev Updater. Pass: the APK installs over the Gradle-built one and the notification service starts. + **Not blocked by E3's layout**, and one thing E5 will need to + account for. `app/shellApp/`'s two Java classes and generated + `PinnedCa.java` are plain `javac` input with no Kotlin of their own + (`android-shell/src/settings.rs`'s doc explains why `kotlinc` still + appears in the graph regardless: `:link`, the shared `wg-app-link` + submodule, is Kotlin and is a real dependency of this module, not + something E3 introduced). `android-shell` itself builds with plain + `cargo ndk build --release -o .../jniLibs/`, exactly the shape "Can + the APK be built without Gradle?" assumed. What E5 will actually + need to solve that E3 did not: producing `:link`'s classes (or an + equivalent Keystore-sealed-token implementation) without inflating + the toolchain E5 is trying to shrink -- either accept `kotlinc` for + that one shared submodule, prebuild it once into a jar/aar E5 + consumes as a binary input, or reimplement the Keystore sealing in + Rust after all (rejected in E3 for reuse and phone-compatibility + reasons, but the calculus is different once Kotlin is otherwise + gone). + ### The iris track These build iris up to carry the app. Each is a feature added to iris diff --git a/android-shell/Cargo.lock b/android-shell/Cargo.lock new file mode 100644 index 0000000..0e0a2df --- /dev/null +++ b/android-shell/Cargo.lock @@ -0,0 +1,1081 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-shell" +version = "0.1.0" +dependencies = [ + "android_logger", + "client-core", + "jni", + "log", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "client-core" +version = "0.1.0" +dependencies = [ + "event-model", + "serde", + "serde_json", + "ureq", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "event-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/android-shell/Cargo.toml b/android-shell/Cargo.toml new file mode 100644 index 0000000..3758acd --- /dev/null +++ b/android-shell/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "android-shell" +version = "0.1.0" +edition = "2024" + +# The JNI bridge behind E3's two Java stub classes (`MainActivity`, +# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for +# why those two classes cannot be anything but Java/Kotlin, registered from +# the manifest by name). Everything they would otherwise have done in +# Kotlin -- the SSE follow loop, deciding where a notification is shown, +# picking a session for a share -- is here instead, built on `client-core` +# so the networking and parsing are not duplicated a third time next to the +# server and the Kotlin app. +# +# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run +# on a normal host target without an Android NDK toolchain, the same +# posture `client-core` and `server` already have. + +[lib] +name = "android_shell" +crate-type = ["cdylib", "lib"] + +[dependencies] +client-core = { path = "../client-core" } +jni = "0.22" +log = "0.4" + +# `LogErrorAndDefault` (the `native_method!` error policy this crate uses +# throughout, see lib.rs) logs through the `log` facade, which is a no-op +# without a backend installed -- so without this, every recoverable error +# at a native entry point would be silently dropped rather than reaching +# logcat. Android-only: nothing else here needs it, and it does not build +# off-device (see `notify::ensure_logger`'s call site, the only place this +# is used). +[target.'cfg(target_os = "android")'.dependencies] +android_logger = "0.15" diff --git a/android-shell/src/jcall.rs b/android-shell/src/jcall.rs new file mode 100644 index 0000000..c647178 --- /dev/null +++ b/android-shell/src/jcall.rs @@ -0,0 +1,152 @@ +//! Thin wrappers around the five `Env` calls this crate makes constantly +//! (a class name, a method name and a signature, all as plain `&str`). +//! +//! `jni` 0.22 wants a class or method *name* as `AsRef` (its own +//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used +//! here uniformly rather than switching to the compile-time `jni_str!` +//! literal macro call by call -- these are a handful of short, one-off +//! lookups, not a hot loop, so the difference is not worth two code paths +//! for the same thing) and a *signature* as a parsed `MethodSignature`/ +//! `FieldSignature`, which is why those go through +//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the +//! parsed form is what lets these calls skip re-validating the signature +//! against the arguments on every call, which is the whole reason `jni` +//! moved to it. +//! +//! **The classloader gotcha, found by testing (2026-09-05).** A class +//! lookup by name (`find_class`, `new_object`, `call_static_method`, +//! `get_static_field` -- anything that resolves a *class*, as opposed to +//! `call_method` on an object it already has, which needs no such lookup) +//! defaults to `FindClass`'s ordinary search when it cannot find the +//! calling thread a classloader through `Thread.getContextClassLoader()`. +//! That default is fine on a thread the JVM itself started -- an +//! `onCreate`/`onStartCommand` callback -- but every one of these calls +//! from `android-shell`'s own background thread (the notification +//! follow-loop, the share upload) is running on a thread *Rust* spawned +//! and attached with `JavaVM::attach_current_thread`, which the platform +//! never gave an app classloader. Framework classes +//! (`android.app.Notification$Builder`, ...) still resolve, because they +//! are reachable from the bootstrap loader `FindClass` falls back to -- +//! `androidx.core.app.NotificationManagerCompat` is not, since it is +//! packaged inside this app's own APK. The failure was +//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault` +//! as "failed to resolve Java class ... (class not found or linkage +//! error)" -- on a real device this reads as "the notification silently +//! never arrives," since the whole call is inside the follow loop and the +//! ongoing foreground notification (built on the main thread, in +//! `try_start`, before the background thread exists) posts fine either +//! way. `remember_class_loader` caches the app's own `ClassLoader` the +//! first time any entry point has a `Context` to ask, and every class +//! lookup below goes through it explicitly via `LoaderContext::Loader` +//! rather than the thread-dependent default -- so it is correct on the +//! main thread and on this crate's own background threads alike. + +use jni::Env; +use jni::errors::Result; +use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned}; +use jni::refs::{Global, LoaderContext}; +use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature}; +use jni::strings::JNIString; +use std::sync::OnceLock; + +static CLASS_LOADER: OnceLock>> = OnceLock::new(); + +/// Caches `context`'s own `ClassLoader`, the first time this is called. +/// Cheap to call from every entry point that has a `Context` on hand +/// (`MainActivity`'s and `NotificationService`'s all do): later calls are +/// a `OnceLock::get` and nothing else. +pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> { + if CLASS_LOADER.get().is_some() { + return Ok(()); + } + // context.getClass().getClassLoader() -- resolved via `call_method` on + // real objects throughout, so this needs no class-name lookup of its + // own and has nothing to bootstrap. + let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?; + let loader_obj = call_method( + env, + &class_obj, + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + )? + .l()?; + let loader = env.cast_local::(loader_obj)?; + let global = env.new_global_ref(&loader)?; + // Lost the race with another entry point calling this concurrently -- + // both loaders name the same app, so either one is fine and there is + // nothing to reconcile. + let _ = CLASS_LOADER.set(global); + Ok(()) +} + +/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`) +/// through the cached app classloader when one has been remembered, and +/// through the ordinary default otherwise -- which is every call made +/// before any entry point has run, and is also correct for a main-thread +/// caller, so there is no case this makes worse. +fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result> { + match CLASS_LOADER.get() { + Some(loader) => { + let binary_name = name.replace('/', "."); + LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true) + } + None => env.find_class(JNIString::new(name)), + } +} + +pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result> { + resolve_class(env, name) +} + +/// A new Java string as a plain `JObject` -- what every call site here +/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and +/// `JString: Into` is the documented way across). +pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef) -> Result> { + Ok(env.new_string(text)?.into()) +} + +pub fn new_object<'local>( + env: &mut Env<'local>, + class: &str, + sig: &str, + args: &[JValue], +) -> Result> { + let sig = RuntimeMethodSignature::from_str(sig)?; + let class = resolve_class(env, class)?; + env.new_object(class, sig.method_signature(), args) +} + +pub fn call_method<'local>( + env: &mut Env<'local>, + obj: &JObject, + method: &str, + sig: &str, + args: &[JValue], +) -> Result> { + let sig = RuntimeMethodSignature::from_str(sig)?; + env.call_method(obj, JNIString::new(method), sig.method_signature(), args) +} + +pub fn call_static_method<'local>( + env: &mut Env<'local>, + class: &str, + method: &str, + sig: &str, + args: &[JValue], +) -> Result> { + let sig = RuntimeMethodSignature::from_str(sig)?; + let class = resolve_class(env, class)?; + env.call_static_method(class, JNIString::new(method), sig.method_signature(), args) +} + +pub fn get_static_field<'local>( + env: &mut Env<'local>, + class: &str, + field: &str, + sig: &str, +) -> Result> { + let sig = RuntimeFieldSignature::from_str(sig)?; + let class = resolve_class(env, class)?; + env.get_static_field(class, JNIString::new(field), sig.field_signature()) +} diff --git a/android-shell/src/lib.rs b/android-shell/src/lib.rs new file mode 100644 index 0000000..a84fcd3 --- /dev/null +++ b/android-shell/src/lib.rs @@ -0,0 +1,131 @@ +//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s +//! package comment for what this crate is and RUST.md's E3 entry for the +//! design decisions. +//! +//! Each native method is declared with `jni`'s [`native_method!`] macro +//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`: +//! the macro derives the mangled export name and the JNI signature from the +//! Rust function itself, so the two cannot drift apart the way a +//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature +//! routinely do. `error_policy = LogErrorAndDefault` matches +//! `Notifications.kt`'s own posture: a failure here (a lost connection, a +//! JNI call that threw) is reported to logcat, not thrown back into Java +//! as an exception that would crash the app over something recoverable. +//! +//! Each `const _: NativeMethod = native_method! { ... };` binding is +//! otherwise unused by name -- `_` is the idiomatic way to keep a +//! side-effecting const (here, generating the `#[export_name]`d function +//! the JVM resolves by the JNI naming convention) without a `dead_code` +//! warning for a binding nothing reads. + +mod jcall; +mod notify; +mod settings; +mod share; + +use jni::errors::LogErrorAndDefault; +use jni::objects::{JClass, JObject}; +use jni::sys::jint; +use jni::{Env, NativeMethod, native_method}; + +/// Installs the `log` backend that routes to logcat, once per process. +/// Without it, `LogErrorAndDefault` (every native method below) and any +/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback) +/// call into the `log` facade's default no-op logger, and a real failure +/// vanishes with nothing on logcat to say so -- silently *more* wrong than +/// crashing, since nothing on screen or in the log says a notification was +/// dropped. Called from every entry point below rather than a Java-side +/// `Application.onCreate`, since this crate deliberately has no such class +/// to hook (see RUST.md's E3 entry on the two-Java-classes floor). +fn ensure_logger() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + #[cfg(target_os = "android")] + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Debug) + .with_tag("android-shell"), + ); + }); +} + +// The parameters are spelled as their Java types, not as `JObject`: the +// macro encodes each argument into the exported symbol's JNI signature +// (and JNI resolves `Java_...` names *by* that signature), so a generic +// `JObject` here would export `(Ljava/lang/Object;...)` against a Java +// method actually declared `(Landroid/app/Activity;...)` -- two different +// symbols that never resolve to each other, silently, with no compiler +// error on either side. `android.app.Activity` etc. have no dedicated +// Rust wrapper in this crate, so they fall back to plain `JObject` in the +// implementation functions below (the "Built-in Types" note in +// `native_method!`'s docs). +const _: NativeMethod = native_method! { + java_type = "com.example.aiapp.shell.MainActivity", + static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (), + error_policy = LogErrorAndDefault, +}; + +/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and +/// `onNewIntent`. See `share::handle_intent` for what an intent can mean. +fn native_handle_intent<'local>( + env: &mut Env<'local>, + _class: JClass<'local>, + activity: JObject<'local>, + intent: JObject<'local>, +) -> Result<(), jni::errors::Error> { + ensure_logger(); + jcall::remember_class_loader(env, &activity)?; + share::handle_intent(env, &activity, &intent) +} + +const _: NativeMethod = native_method! { + java_type = "com.example.aiapp.shell.NotificationService", + static extern fn native_sync(context: android.content.Context) -> (), + error_policy = LogErrorAndDefault, +}; + +/// `NotificationService.nativeSync` -- called both from `MainActivity` (an +/// enrollment may have just landed) and from `NotificationService.sync` +/// itself. See `notify::sync`. +fn native_sync<'local>( + env: &mut Env<'local>, + _class: JClass<'local>, + context: JObject<'local>, +) -> Result<(), jni::errors::Error> { + ensure_logger(); + jcall::remember_class_loader(env, &context)?; + notify::sync(env, &context) +} + +const _: NativeMethod = native_method! { + java_type = "com.example.aiapp.shell.NotificationService", + static extern fn native_on_start_command(service: android.app.Service) -> jint, + error_policy = LogErrorAndDefault, +}; + +/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`. +fn native_on_start_command<'local>( + env: &mut Env<'local>, + _class: JClass<'local>, + service: JObject<'local>, +) -> Result { + ensure_logger(); + jcall::remember_class_loader(env, &service)?; + Ok(notify::on_start_command(env, service)) +} + +const _: NativeMethod = native_method! { + java_type = "com.example.aiapp.shell.NotificationService", + static extern fn native_on_destroy() -> (), + error_policy = LogErrorAndDefault, +}; + +/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`. +fn native_on_destroy<'local>( + _env: &mut Env<'local>, + _class: JClass<'local>, +) -> Result<(), jni::errors::Error> { + ensure_logger(); + notify::on_destroy(); + Ok(()) +} diff --git a/android-shell/src/notify.rs b/android-shell/src/notify.rs new file mode 100644 index 0000000..aa96259 --- /dev/null +++ b/android-shell/src/notify.rs @@ -0,0 +1,556 @@ +//! 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(()) + })(); +} diff --git a/android-shell/src/settings.rs b/android-shell/src/settings.rs new file mode 100644 index 0000000..c981086 --- /dev/null +++ b/android-shell/src/settings.rs @@ -0,0 +1,141 @@ +//! Enrollment: where the backend is, and the Keystore-sealed token to +//! reach it. This crate does not reimplement the Android Keystore AES-GCM +//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin +//! class the production app already uses (see `ServerConfig.kt`), through +//! JNI, for two reasons: that code is shared with Dev Updater and already +//! tested, and the sealed value on a real phone is keyed to the exact +//! Keystore alias that class already uses -- reimplementing the crypto +//! here would either duplicate it or invalidate an existing enrollment. + +use jni::Env; +use jni::errors::Result; +use jni::objects::{JObject, JString, JValue}; + +/// Where the backend is and how to authenticate to it -- the Rust twin of +/// `wg-app-link`'s `ServerSettings` data class, read back field by field +/// rather than kept as a live JNI reference, so it can cross a thread +/// boundary (a `JObject` is tied to one `Env`/thread). +#[derive(Debug, Clone)] +pub struct ServerSettings { + pub host: String, + pub port: i32, + pub token: String, +} + +impl ServerSettings { + pub fn base_url(&self) -> String { + format!("https://{}:{}", self.host, self.port) + } +} + +/// This experiment's own scheme and Keystore alias -- distinct from the +/// production app's (`aiapp` / `aiapp-token-key`) so the two can be +/// installed side by side on the same development device without +/// colliding over which one a scanned QR or a deep link resolves to. See +/// RUST.md's E3 entry for why they are not the same value. +pub(crate) const SCHEME: &str = "aiappshell"; +const KEY_ALIAS: &str = "aiapp-shell-token-key"; +const STORE_CLASS: &str = "com/example/wgapplink/ServerStore"; +const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings"; + +fn new_store<'l>(env: &mut Env<'l>) -> Result> { + let scheme = crate::jcall::jstr_obj(env, SCHEME)?; + let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?; + crate::jcall::new_object( + env, + STORE_CLASS, + "(Ljava/lang/String;Ljava/lang/String;)V", + &[JValue::Object(&scheme), JValue::Object(&alias)], + ) +} + +fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result { + let host = get_string(env, settings_obj, "getHost")?; + let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?; + let token = get_string(env, settings_obj, "getToken")?; + Ok(ServerSettings { host, port, token }) +} + +fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result { + let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?; + let jstr: JString = env.cast_local::(value)?; + jstr.try_to_string(env) +} + +/// The stored enrollment, or `None` when there is not one -- mirrors +/// `ServerConfig.kt`'s `loadServerSettings`. +pub fn load(env: &mut Env, context: &JObject) -> Result> { + let store = new_store(env)?; + let settings_obj = crate::jcall::call_method( + env, + &store, + "load", + "(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;", + &[JValue::Object(context)], + )? + .l()?; + if settings_obj.is_null() { + return Ok(None); + } + Ok(Some(read_settings(env, &settings_obj)?)) +} + +/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`. +pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> { + let store = new_store(env)?; + let host = crate::jcall::jstr_obj(env, &settings.host)?; + let token = crate::jcall::jstr_obj(env, &settings.token)?; + let settings_obj = crate::jcall::new_object( + env, + SETTINGS_CLASS, + "(Ljava/lang/String;ILjava/lang/String;)V", + &[ + JValue::Object(&host), + JValue::Int(settings.port), + JValue::Object(&token), + ], + )?; + crate::jcall::call_method( + env, + &store, + "save", + "(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V", + &[JValue::Object(context), JValue::Object(&settings_obj)], + )?; + Ok(()) +} + +/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s +/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the +/// query-parameter rules rather than re-deriving them here. +pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result> { + let store = new_store(env)?; + let settings_obj = crate::jcall::call_method( + env, + &store, + "parseEnrollmentUri", + "(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;", + &[JValue::Object(uri)], + )? + .l()?; + if settings_obj.is_null() { + return Ok(None); + } + Ok(Some(read_settings(env, &settings_obj)?)) +} + +/// The CA this build pins, generated at build time the same way +/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`) +/// but into a plain Java constant, since this module has no Kotlin of its +/// own to generate into. +pub fn load_pinned_ca(env: &mut Env) -> Result> { + let value = crate::jcall::get_static_field( + env, + "com/example/aiapp/shell/PinnedCa", + "PINNED_CA_PEM", + "Ljava/lang/String;", + )? + .l()?; + let jstr: JString = env.cast_local::(value)?; + Ok(jstr.try_to_string(env)?.into_bytes()) +} diff --git a/android-shell/src/share.rs b/android-shell/src/share.rs new file mode 100644 index 0000000..f7739c9 --- /dev/null +++ b/android-shell/src/share.rs @@ -0,0 +1,183 @@ +//! 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) +} diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts index 18ed8d1..71bf9cf 100644 --- a/app/settings.gradle.kts +++ b/app/settings.gradle.kts @@ -17,6 +17,11 @@ dependencyResolutionManagement { include(":androidApp") +// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a +// separate module from :androidApp so the ~13,000 lines of working Compose +// UI there are untouched. See shellApp/build.gradle.kts's module comment. +include(":shellApp") + // The app half of wg-app-link, resolved by path through the submodule so // this checkout and the crate it consumes move together -- the same // arrangement `server/` uses for the Rust half. See that repo's README. diff --git a/app/shellApp/build.gradle.kts b/app/shellApp/build.gradle.kts new file mode 100644 index 0000000..b97e961 --- /dev/null +++ b/app/shellApp/build.gradle.kts @@ -0,0 +1,115 @@ +plugins { alias(libs.plugins.androidApplication) } + +// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge +// into Rust (`../../android-shell`). Deliberately its own module rather +// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines +// of working Compose UI this experiment does not touch, and the two can be +// installed side by side on the same development device (see +// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme +// and Keystore alias are not the production app's). No Compose plugin, no +// Kotlin source of its own: `MainActivity`/`NotificationService` are plain +// Java, and the CA constant below is generated as Java too. +// +// The CA this build pins is baked in the same way `androidApp`'s does -- +// see that module's `build.gradle.kts` comment for the reasoning (the +// trust boundary follows the machine that builds, never a pasted copy). +// `PinnedCa.java`'s package must match `android-shell`'s +// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`). +val pinnedCaPath: String = + System.getenv("AI_APP_CA") + ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + + "/ai-app/certs/ca.pem" + +abstract class GeneratePinnedCa : DefaultTask() { + @get:Input abstract val caPath: Property + + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val caCertificate: RegularFileProperty + + @get:OutputDirectory abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val path = caPath.get() + val ca = File(path) + if (!ca.isFile) { + throw GradleException( + "No CA certificate at $path.\n" + + "Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " + + "generates the CA this build pins.\n" + + "Set AI_APP_CA=/path/to/ca.pem to build against a different one." + ) + } + val pem = ca.readText().trim() + if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) { + throw GradleException("$path is not a PEM certificate.") + } + val dir = outputDir.get().dir("com/example/aiapp/shell").asFile + dir.mkdirs() + // Same reasoning as androidApp's generatePinnedCert: the text block + // must start immediately after the opening `"""`, or + // CertificateFactory stops recognising the "-----BEGIN" preamble. + File(dir, "PinnedCa.java") + .writeText( + """ + |// Generated from $path by the generatePinnedCa task. Do not edit. + |package com.example.aiapp.shell; + | + |public final class PinnedCa { + | private PinnedCa() {} + | public static final String PINNED_CA_PEM = ""${'"'} + |$pem""${'"'}; + |} + |""" + .trimMargin() + ) + } +} + +val generatePinnedCa = + tasks.register("generatePinnedCa") { + val ca = file(pinnedCaPath) + caPath.set(pinnedCaPath) + if (ca.isFile) { + caCertificate.set(ca) + } + } + +android { + namespace = "com.example.aiapp.shell" + compileSdk = 37 + + defaultConfig { + applicationId = "com.example.aiapp.shell" + minSdk = 24 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +androidComponents { + onVariants { variant -> + variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir) + } +} + +dependencies { + // The Keystore-sealed enrollment (ServerStore/ServerSettings) -- + // android-shell's settings.rs calls into this Kotlin class directly + // over JNI rather than re-sealing the token in Rust; see that file's + // module doc. + implementation(project(":link")) + // NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/ + // ServiceCompat -- android-shell's notify.rs calls these classes over + // JNI so the pre-26 fallback behaviour (no channels) lives once, in + // the library that already has it, rather than being re-derived as a + // set of Build.VERSION.SDK_INT branches in Rust. + implementation(libs.androidx.core.ktx) +} diff --git a/app/shellApp/src/main/AndroidManifest.xml b/app/shellApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b40a7f7 --- /dev/null +++ b/app/shellApp/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java b/app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java new file mode 100644 index 0000000..7f8be9c --- /dev/null +++ b/app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java @@ -0,0 +1,50 @@ +package com.example.aiapp.shell; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; + +/** + * E3's floor, per RUST.md's "How much Java is unavoidable": a class the framework + * constructs by name from the manifest, with its lifecycle methods handing straight to Rust + * (android-shell's {@code share::handle_intent}). No Compose, no layout -- there is no screen to + * draw yet (that is E4's job, on iris); {@link #toast} is this experiment's stand-in for showing + * something happened. + */ +public class MainActivity extends Activity { + static { + System.loadLibrary("android_shell"); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + NotificationService.sync(this); + nativeHandleIntent(this, getIntent()); + } + + // launchMode="singleTop": a notification tap or a share while this activity is already on + // top lands here rather than in a second instance -- same reasoning as MainActivity.kt's. + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + nativeHandleIntent(this, intent); + } + + /** + * Called from android-shell, sometimes from a background thread (a share's network call is + * never made on the calling thread -- see share.rs). {@code Toast} itself is main-thread-only, + * so this hops there with a {@link Handler} rather than assuming the caller already has. + */ + static void toast(Context context, String message) { + new Handler(Looper.getMainLooper()) + .post(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show()); + } + + private static native void nativeHandleIntent(Activity activity, Intent intent); +} diff --git a/app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java b/app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java new file mode 100644 index 0000000..79e90d6 --- /dev/null +++ b/app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java @@ -0,0 +1,45 @@ +package com.example.aiapp.shell; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; + +/** + * E3's second unavoidable Java class (RUST.md): a foreground service constructed by the framework + * from the manifest, existing only to hand its lifecycle to android-shell's {@code notify} module + * -- the SSE follow loop, deciding what a notification says, and posting it are all Rust reached + * through these three native calls. See {@code Notifications.kt}'s {@code NotificationService} for + * the Kotlin original this mirrors. + */ +public class NotificationService extends Service { + static { + System.loadLibrary("android_shell"); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return nativeOnStartCommand(this); + } + + @Override + public void onDestroy() { + nativeOnDestroy(); + } + + /** Starts this service if there is a server to connect to, and stops it otherwise. */ + static void sync(Context context) { + nativeSync(context); + } + + private static native void nativeSync(Context context); + + private static native int nativeOnStartCommand(Service service); + + private static native void nativeOnDestroy(); +} diff --git a/client-core/src/lib.rs b/client-core/src/lib.rs index a7de40d..0e4d8d6 100644 --- a/client-core/src/lib.rs +++ b/client-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod ansi; pub mod api; pub mod event_stream; pub mod highlight; +pub mod notifications; pub mod sse; pub mod transcript_cache; pub mod transcript_fold; diff --git a/client-core/src/notifications.rs b/client-core/src/notifications.rs new file mode 100644 index 0000000..8b86a90 --- /dev/null +++ b/client-core/src/notifications.rs @@ -0,0 +1,162 @@ +//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two +//! places, never both" describes. Ported from the parsing half of +//! `app/.../Notifications.kt`'s `NotificationService` -- the framing +//! ([`crate::sse`]) and the wire shape ([`SessionNotification`], +//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s +//! `Notification`/`NotificationKind`). +//! +//! What is deliberately **not** here, because it is a decision rather than +//! logic: whether a given notification is shown at all (the session on +//! screen gets nothing), handed to the app as a banner, or posted to the +//! platform's own notification drawer. That three-way choice reads +//! process-wide state (what screen is open, whether the app is in front) +//! that has no meaning to a pure crate with no UI and no Android in it -- +//! see `android-shell` for where it lives for this port. + +use std::io::{BufRead, BufReader}; + +use serde::Deserialize; + +use crate::api::{ApiError, Transport}; +use crate::sse::SseReader; + +/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s +/// `Notification` field for field. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionNotification { + pub session_id: String, + pub title: String, + pub kind: NotificationKind, + /// Epoch seconds, so a phone that was asleep can say how long ago. + pub at: f64, +} + +/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized +/// the same way, so this deserializes the wire's `"awaitingInput"` / +/// `"finished"` directly rather than through a string match. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NotificationKind { + AwaitingInput, + Finished, +} + +impl NotificationKind { + /// What a notification asks of the reader, in the words they see -- + /// ported verbatim from `Notifications.kt`'s `attentionLine`. One + /// function because the same fact is shown in two places (the + /// platform's drawer and the app's own banner) and two mappings of one + /// word drift. + pub fn attention_line(self) -> &'static str { + match self { + NotificationKind::AwaitingInput => "Waiting for you", + NotificationKind::Finished => "Finished", + } + } +} + +/// Follows `/notifications`, calling `on_notification` for each frame until +/// the connection drops or the callback asks to stop (by returning +/// `false`). Reconnecting is the caller's job -- mirroring +/// `NotificationService.follow`'s retry loop, which is a platform policy +/// (how long to wait, whether to give up) rather than parsing logic. +pub fn follow_notifications( + transport: &dyn Transport, + mut on_notification: impl FnMut(SessionNotification) -> bool, +) -> Result<(), ApiError> { + let body = transport.stream("/notifications")?; + let mut lines = BufReader::new(body).lines(); + let mut reader = SseReader::new(); + while let Some(line) = lines.next().transpose().map_err(|e| ApiError { + message: format!("Can't reach the server -- retrying. ({e})"), + status: None, + })? { + let Some(frame) = reader.feed_line(&line) else { + continue; + }; + if frame.data.is_empty() { + continue; + } + let notification: SessionNotification = + serde_json::from_str(&frame.data).map_err(|e| ApiError { + message: format!("The server sent a notification this build couldn't parse: {e}"), + status: None, + })?; + if !on_notification(notification) { + return Ok(()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{Body, RawResponse}; + use std::io::Cursor; + + struct FixtureTransport { + body: &'static str, + } + + impl Transport for FixtureTransport { + fn request( + &self, + _method: &str, + _path: &str, + _body: Option, + ) -> Result { + unimplemented!("this fixture only serves a stream") + } + + fn stream(&self, _path: &str) -> Result, ApiError> { + Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec()))) + } + } + + #[test] + fn a_notification_frame_parses_both_kinds() { + let transport = FixtureTransport { + body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\ + data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n", + }; + let mut seen = Vec::new(); + follow_notifications(&transport, |n| { + seen.push((n.session_id, n.kind)); + true + }) + .unwrap(); + assert_eq!( + seen, + vec![ + ("s1".to_string(), NotificationKind::AwaitingInput), + ("s2".to_string(), NotificationKind::Finished), + ] + ); + } + + #[test] + fn the_caller_can_stop_early() { + let transport = FixtureTransport { + body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\ + data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n", + }; + let mut count = 0; + follow_notifications(&transport, |_| { + count += 1; + count < 1 + }) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn attention_line_matches_the_kotlin_original() { + assert_eq!( + NotificationKind::AwaitingInput.attention_line(), + "Waiting for you" + ); + assert_eq!(NotificationKind::Finished.attention_line(), "Finished"); + } +}