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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8adda94a7a
commit
c9b273ff16
16 files changed
+2996
-6
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
app/androidApp/build/
|
app/androidApp/build/
|
||||||
|
app/shellApp/build/
|
||||||
local.properties
|
local.properties
|
||||||
.kotlin/
|
.kotlin/
|
||||||
*.iml
|
*.iml
|
||||||
@@ -9,6 +10,11 @@ local.properties
|
|||||||
server/target/
|
server/target/
|
||||||
event-model/target/
|
event-model/target/
|
||||||
client-core/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,
|
# Server logs from a development run (ai-server.log by convention,
|
||||||
# wg-test.log from ./test-wg-tunnel.sh).
|
# wg-test.log from ./test-wg-tunnel.sh).
|
||||||
|
|||||||
@@ -39,8 +39,28 @@ session spending an afternoon on them again.
|
|||||||
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
||||||
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
|
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
|
||||||
which found that Masonry has no touch-scroll on Android at all — see
|
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 +
|
below), **E3 (the Kotlin/Java shell over a JNI bridge into Rust, both
|
||||||
glyph atlas).
|
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
|
- **E2 done, 2026-09-05, and its headline finding changes what "decide
|
||||||
from the measurements" (recommendation item 3) can mean right now.**
|
from the measurements" (recommendation item 3) can mean right now.**
|
||||||
Built a real transcript screen (`~/src/android-view/e2-transcript`,
|
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
|
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
|
point in iris's favour that a frame-time number would not have
|
||||||
shown any more clearly.
|
shown any more clearly.
|
||||||
- [ ] **E3 — the shell.** Kotlin `MainActivity` + `NotificationService`
|
- [x] **E3 — the shell (2026-09-05).** Both pass-condition proofs held on
|
||||||
+ Keystore + share intent calling into Rust over JNI, with the SSE
|
the emulator: a notification arrived while the app was closed, and a
|
||||||
follow loop in Rust. Pass: a notification arrives with the app
|
shared text share landed as a real message in a session's transcript.
|
||||||
closed, and a share lands in a session.
|
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=<sandbox port>&token=<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 <sid> "/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/<sid>/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
|
- [ ] **E4 — the same screen on the desktop** in a winit window, from the
|
||||||
same crate, with only the layout differing.
|
same crate, with only the layout differing.
|
||||||
- [ ] **E5 — the packaging xtask**: `cargo ndk` → `javac`/`d8` → `aapt2`
|
- [ ] **E5 — the packaging xtask**: `cargo ndk` → `javac`/`d8` → `aapt2`
|
||||||
@@ -980,6 +1226,24 @@ accepted.
|
|||||||
installed through Dev Updater. Pass: the APK installs over the
|
installed through Dev Updater. Pass: the APK installs over the
|
||||||
Gradle-built one and the notification service starts.
|
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
|
### The iris track
|
||||||
|
|
||||||
These build iris up to carry the app. Each is a feature added to iris
|
These build iris up to carry the app. Each is a feature added to iris
|
||||||
|
|||||||
Generated
+1081
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
@@ -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<JNIStr>` (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<Global<JClassLoader<'static>>> = 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::<JClassLoader>(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<JClass<'local>> {
|
||||||
|
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<JClass<'local>> {
|
||||||
|
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<JObject>` is the documented way across).
|
||||||
|
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||||
|
Ok(env.new_string(text)?.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_object<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
class: &str,
|
||||||
|
sig: &str,
|
||||||
|
args: &[JValue],
|
||||||
|
) -> Result<JObject<'local>> {
|
||||||
|
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<JValueOwned<'local>> {
|
||||||
|
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<JValueOwned<'local>> {
|
||||||
|
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<JValueOwned<'local>> {
|
||||||
|
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||||
|
let class = resolve_class(env, class)?;
|
||||||
|
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||||
|
}
|
||||||
@@ -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<jint, jni::errors::Error> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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<i32> {
|
||||||
|
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
|
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<JObject<'l>> {
|
||||||
|
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<JObject<'l>> {
|
||||||
|
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<JObject<'l>> {
|
||||||
|
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<i32> {
|
||||||
|
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<JObject<'l>> {
|
||||||
|
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<bool> {
|
||||||
|
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(())
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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<JObject<'l>> {
|
||||||
|
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<ServerSettings> {
|
||||||
|
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<String> {
|
||||||
|
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
|
let jstr: JString = env.cast_local::<JString>(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<Option<ServerSettings>> {
|
||||||
|
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<Option<ServerSettings>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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::<JString>(value)?;
|
||||||
|
Ok(jstr.try_to_string(env)?.into_bytes())
|
||||||
|
}
|
||||||
@@ -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<Option<String>> {
|
||||||
|
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
|
Ok(Some(jstr.try_to_string(env)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||||
|
let message = crate::jcall::jstr_obj(env, message)?;
|
||||||
|
crate::jcall::call_static_method(
|
||||||
|
env,
|
||||||
|
"com/example/aiapp/shell/MainActivity",
|
||||||
|
"toast",
|
||||||
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(context), JValue::Object(&message)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one place an incoming intent is sorted into what it means -- mirrors
|
||||||
|
/// `MainActivity.kt`'s `handleIntent`.
|
||||||
|
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
|
let action = get_string_method(env, intent, "getAction")?;
|
||||||
|
if matches!(
|
||||||
|
action.as_deref(),
|
||||||
|
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
||||||
|
) {
|
||||||
|
return handle_share(env, activity, intent);
|
||||||
|
}
|
||||||
|
if action.as_deref() != Some(ACTION_VIEW) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
||||||
|
if uri.is_null() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let scheme = get_string_method(env, &uri, "getScheme")?;
|
||||||
|
if scheme.as_deref() != Some(settings::SCHEME) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
||||||
|
Some("session") => handle_session_open(env, activity, &uri),
|
||||||
|
Some("enroll") => handle_enrollment(env, activity, &uri),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||||
|
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// There is no session screen yet (E4's job); the toast is this
|
||||||
|
// experiment's stand-in proof that the tap was routed to the right
|
||||||
|
// session id.
|
||||||
|
toast(env, activity, &format!("Opened session {session_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||||
|
match settings::parse_enrollment_uri(env, uri)? {
|
||||||
|
Some(parsed) => {
|
||||||
|
settings::save(env, activity, &parsed)?;
|
||||||
|
notify::sync(env, activity)?;
|
||||||
|
toast(
|
||||||
|
env,
|
||||||
|
activity,
|
||||||
|
&format!("Enrolled with {}", parsed.base_url()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => toast(env, activity, "Not a valid enrollment code"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
||||||
|
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
||||||
|
/// what happens to it, minus attachments per this module's doc comment.
|
||||||
|
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
|
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||||
|
let text = crate::jcall::call_method(
|
||||||
|
env,
|
||||||
|
intent,
|
||||||
|
"getStringExtra",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
&[JValue::Object(&extra_text)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let text = if text.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let jstr: JString = env.cast_local::<JString>(text)?;
|
||||||
|
Some(jstr.try_to_string(env)?)
|
||||||
|
};
|
||||||
|
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
||||||
|
return toast(
|
||||||
|
env,
|
||||||
|
activity,
|
||||||
|
"Nothing to share -- only shared text is supported so far",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Network I/O must not run on the calling thread: `handle_intent` is
|
||||||
|
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
||||||
|
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
||||||
|
// the actual send happens on a JNI-attached background thread, the
|
||||||
|
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
||||||
|
// thread is safe because `MainActivity.toast` itself hops back to the
|
||||||
|
// main looper (see that method).
|
||||||
|
let vm = env.get_java_vm()?;
|
||||||
|
let activity_ref = env.new_global_ref(activity)?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
|
share_in_background(env, &activity_ref, text);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
||||||
|
let outcome = attach_to_a_session(env, activity, &text);
|
||||||
|
let message = match outcome {
|
||||||
|
Ok(title) => format!("Shared into \"{title}\""),
|
||||||
|
Err(message) => message,
|
||||||
|
};
|
||||||
|
let _ = toast(env, activity, &message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_to_a_session(
|
||||||
|
env: &mut Env,
|
||||||
|
activity: &JObject,
|
||||||
|
text: &str,
|
||||||
|
) -> std::result::Result<String, String> {
|
||||||
|
let settings = settings::load(env, activity)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
||||||
|
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
||||||
|
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let client = ApiClient::new(transport);
|
||||||
|
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
||||||
|
let target = sessions
|
||||||
|
.into_iter()
|
||||||
|
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
||||||
|
.ok_or_else(|| "No session to share into".to_string())?;
|
||||||
|
client
|
||||||
|
.send_message(&target.id, text, &[])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(target.title)
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
include(":androidApp")
|
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
|
// 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
|
// this checkout and the crate it consumes move together -- the same
|
||||||
// arrangement `server/` uses for the Rust half. See that repo's README.
|
// arrangement `server/` uses for the Rust half. See that repo's README.
|
||||||
|
|||||||
@@ -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<String>
|
||||||
|
|
||||||
|
@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>("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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Mirrors androidApp's manifest (AGENTS.md: reuse it rather than
|
||||||
|
re-deriving it) for the permissions and declarations E3 actually
|
||||||
|
exercises. Not carried over: the QR scanner activity (this
|
||||||
|
experiment enrolls via the aiappshell://enroll deep link directly,
|
||||||
|
per AGENTS.md's ui-sandbox.sh banner) and the app icon warning
|
||||||
|
suppression below, for the same reason androidApp's is there. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="AI Sessions (shell)"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
tools:ignore="MissingApplicationIcon">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Enrollment: aiappshell://enroll?host=...&port=...&token=...,
|
||||||
|
per AGENTS.md's ui-sandbox.sh banner (fed to this app with
|
||||||
|
`adb shell am start -a android.intent.action.VIEW -d
|
||||||
|
'aiappshell://enroll?...'`, or -n'd at this component
|
||||||
|
directly if a second app also claims the aiapp scheme). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="aiappshell" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- The share sheet - see android-shell's share.rs. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- specialUse, not dataSync, for the reason androidApp's manifest
|
||||||
|
gives: a connection that has to keep listening overnight
|
||||||
|
cannot accept dataSync's six-hour cap. -->
|
||||||
|
<service
|
||||||
|
android:name=".NotificationService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="E3 experiment: holds one connection to the sandbox server so a
|
||||||
|
session that needs an answer can be reported while the app is closed." />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod ansi;
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
|
|||||||
@@ -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<Body>,
|
||||||
|
) -> Result<RawResponse, ApiError> {
|
||||||
|
unimplemented!("this fixture only serves a stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user