Skip to content

android: Rework the JNI layer (Activity lifetime, exceptions, UI thread, class cache) - #1

Merged
Bombatomica64 merged 12 commits into
mainfrom
jni-refactor
Oct 9, 2026
Merged

Bombatomica64 merged 12 commits into
mainfrom
jni-refactor

Conversation

@Bombatomica64

Copy link
Copy Markdown
Owner

Refactor of how gpui-mobile talks to Java on Android, from an audit of src/android/jni.rs, the core files and all 30 packages/*/android.rs. Every commit stands alone, builds, and adds no clippy warnings over main. Each is meant to become one small upstream PR later.

Commits

  1. Correct the JNI helper docs. with_env claimed it detaches the thread on return. jni 0.22 attaches permanently, and an exception still pending at the end replaces the closure's result. obtain_env is deprecated, and the unsafe blocks get SAFETY comments.
  2. Clear and describe Java exceptions in the core JNI calls.
    • New JniResultExt::or_clear(env) clears the pending exception and returns its toString(), so errors read java.lang.SecurityException: … instead of JavaException.
    • get_string now checks that the object is a java.lang.String. Before, it read any object as one.
  3. Hand out the host Activity as a local reference. This fixes the most serious problem the audit found:
    • The old code wrapped a raw global ref after releasing the lock and kept only one previous Activity. Two recreations during one call could delete a reference that call was still using.
    • A finished Activity stayed "current" (used and leaked) until the next onCreate.
    • Now set_host_activity registers Activities in a list. activity(env) returns a local ref, taken under the lock, to the newest Activity that is neither finishing nor destroyed, and drops the others.
    • New application_context(). The accessibility bridge goes through activity().
  4. Cache the app class loader and the classes found through it. find_app_class cost three extra JNI calls on each of about 110 call sites and needed an Activity. The loader now comes from the application context, once, and each class found is kept as a global ref.
  5. Run Window and View calls on the UI thread.
    • set_system_chrome and update_ime_position ran on the render thread; the code itself noted a possible deadlock.
    • New run_on_ui_thread posts to the UI thread's looper through an eventfd (NDK only, no Java). It is registered in set_host_activity.
    • The android-activity path has no way to reach the UI thread without Java, so calls run in place there, as before.
  6. Clear and describe Java exceptions in the platform packages. The same or_clear rule, applied mechanically across the packages. Discarded results are logged at debug level, and swallowed errors clear the exception before falling back.
  7. Use the application context where packages only need a Context (getSystemService, getPackageManager, …).
  8. Call ClipboardManager, the launch Intent and the Play Store from Rust.
    • The clipboard, deeplink and in-app-review packages called Java helpers that only forwarded to framework APIs. A host that hadn't copied them got a loadClass error.
    • Same behaviour through JNI now, and GpuiClipboard.java, GpuiDeepLink.java and GpuiInAppReview.java are removed from the example.

Not in scope: behaviour bugs in individual packages, such as unbounded waits in the pickers, auth and permissions, dead webview controls, and content:// URIs reported as paths. They're listed in the audit for later.

Testing

  • cargo fmt --all -- --check and cargo clippy --target aarch64-linux-android --lib --all-features after each commit, with the same warning list as main.
  • On a OnePlus CPH2581 (Android 16), through the lab app (Test: gpui-mobile jni-refactor Gpui-android#24):
    • Three Activity recreations in a row in one process (am start -f NEW_TASK|CLEAR_TASK): no JNI errors.
    • After those recreations, share and the keyboard both worked, which is what exercises the new Activity registry.
    • Typing worked, and the viewport resized for the keyboard.
    • Clipboard copy and read worked, through the new Rust path.
    • Night mode switched at runtime, and emoji rendered.
  • Not tested: TalkBack, and the system bar colours (the lab may not set them). There's no real rotation because the lab is portrait-only.

🤖 Generated with Claude Code

Bombatomica64 and others added 8 commits October 9, 2026 10:50
with_env claimed the thread is detached when the closure returns. jni
0.22's attach_current_thread attaches for good, runs the closure in its
own local frame, and turns an exception still pending at the end into the
returned error, replacing the closure's result. The doc now says so.

obtain_env, an alias of with_env with no callers, is deprecated. The
unsafe blocks in the JNI helpers and exports gain SAFETY comments, and a
stale comment about dropping a JObject (it has no Drop in jni 0.22) goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A call into Java that throws leaves the exception pending. jni 0.22 then
refuses every later JNI call in the same frame, and the error only says
"JavaException". Several calls in platform.rs and jni.rs converted the
error with map_err(to_string) and left the exception pending.

The new JniResultExt::or_clear clears the pending exception and returns
its toString(), so errors read like "java.lang.SecurityException: ...".
The core files' calls into Java use it. get_string now checks that the
object is a java.lang.String before reading it; before, any object handed
to it (nativeIme passes whatever Java sent) was read as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host-driven path kept the current Activity and the previous one as
global references, and activity() wrapped the current one's raw pointer
after releasing the lock. Two Activity creations during one JNI call (a
double rotation) deleted the reference the call was still using, and an
Activity that finished stayed the current one, kept alive and used as a
Context, until the next onCreate.

set_host_activity now adds the Activity to a list, and activity() returns
a local reference to the newest one that is neither finishing nor
destroyed, taken under the lock, and drops the references of those that
are. activity() takes &mut Env for that (every caller already passes
one).

The new application_context() is for calls that only need a Context; the
keyboard layout and thermal status queries use it. The accessibility
bridge takes its Activity through activity() too. activity_as_ptr() is
documented as not to be kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
find_app_class asked the Activity for its class loader and loaded the
class again on every call, three JNI calls on top of the one it served,
for each of the ~100 call sites in the platform packages. It also needed
an Activity, so it failed between two host Activities.

The class loader now comes from the application context, once, and each
class found is kept as a global reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
set_system_chrome (Window.setStatusBarColor and friends) and
update_ime_position (InputMethodManager.updateCursorAnchorInfo with the
decor view) ran on the render thread. Android requires the UI thread for
View and Window calls; the chrome code already noted it could deadlock
against it.

The new run_on_ui_thread posts a closure to the UI thread's looper, woken
through an eventfd, or runs it right away on the UI thread itself. The
host-driven path registers the UI thread in set_host_activity, which runs
in onCreate. The android-activity path cannot reach the UI thread without
Java code, so the closure runs in place there, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pending Java exception makes jni 0.22 refuse every later JNI call in the
frame, and the bare JNI error only said "JavaException". Convert calls into
Java in the platform packages to `or_clear(env)`, which clears the exception
and reports its toString(). Discarded results now log the failure at debug
level, and swallowed errors (`.ok()`, `unwrap_or`, `if let Ok`) clear the
exception before falling back to their default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Activity can finish while a package still holds it, whereas the
application context lives for the process. Packages that only call
Context methods (getSystemService, getPackageManager, getPackageName)
now use `application_context` instead of `activity`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…from Rust

The clipboard, deeplink and in-app review packages called static Java
helpers (GpuiClipboard, GpuiDeepLink, GpuiInAppReview) that only forwarded
to framework APIs. A host that did not copy those files from the example
got a loadClass error at runtime.

They now make the same framework calls through JNI, with the same
behaviour, and the three Java files go. The clipboard and the Play Store
check use the application context; opening the store still starts the
Activity from the current Activity, in a new task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Bombatomica64

Copy link
Copy Markdown
Owner Author

Review of the branch (core files read in full, packages swept, clippy clean on aarch64-linux-android). The design holds up: the Activity registry, or_clear, the class cache and the UI-thread dispatcher match what jni 0.22.4 and ndk 0.9 actually do. No blocking bug. Things I'd change before splitting this for upstream:

Fix

  1. src/packages/in_app_review/android.rs:60 — view(market)? short-circuits on any error before startActivity (Uri.parse, Intent ctor, no Activity), so the https://play.google.com fallback is never tried. The deleted Java caught everything and fell through. view(market).unwrap_or(false) || view(https)?.
  2. Same file, is_available — every exception now reads as "not installed"; Java only caught NameNotFoundException. A missing <queries> entry on API 30+ becomes silent. At least log the cleared description.
  3. jni.rs:585 activity_as_ptr() has no callers left except its own test. It's the only remaining way to hold a raw global the registry may delete. Remove or #[deprecated] it rather than documenting "don't keep".

Description overclaims the sweep

  • ~24 bare env.exception_clear() sites remain (battery, connectivity, maps_launcher, network_info, package_info, sensors, share, url_launcher, vibration). Correct, but they lose the exception text or_clear exists for. sensors/android.rs:66 is now dead code (already cleared on line 64).
  • battery/android.rs:83 and shared_preferences/android.rs:206 still use activity(env) for Context-only calls.
  • vibration/android.rs:12 leaves a find_class failure pending into the fallback vibrate(J) call. Dead with minSdk 26, but contradicts the rule this PR sets.

Pre-existing, touched here
LAST_CHROME_STYLE is only reset on the android-activity TerminateWindow path (jni.rs:1093). On the host path a recreated Activity never gets the chrome re-applied. A None reset in set_host_activity would do.

Size / safety / perf

  • Commits 5, 6 and 8 are two thirds of the diff. Commit 5 is mostly re-indentation; a small run_on_ui_thread variant that owns the (|| -> Result)() + log::warn! boilerplate would halve it. Commit 8 splits naturally per package. 1–4 and 7 are already upstream-sized.
  • activity() on the host path now does isFinishing + isDestroyed per registered Activity, under the mutex, on every call — that's the hot path for every package and gives back some of what the class cache saved. Prune in set_host_activity (and an onDestroy hook) instead, or skip the check when the single registered Activity passed last time.
  • run_on_ui_thread queues into an unbounded Vec; update_ime_position is frequent. If a host's looper stops being polled, tasks pile up silently. Coalescing the IME task to latest-wins, or a bound, is cheap insurance.

Verified: jni 0.22.4 attach_current_thread attaches permanently and turns a pending exception into CaughtJavaException (commit 1's doc is right); ALooper_forThread works on the Java main thread; clipboard and deeplink ports are behaviourally equivalent to the deleted Java; all new JNI signatures and static/instance calls match the framework; nothing under src/packages keeps the local Activity ref past its frame. Not run on a device.

@Bombatomica64

Copy link
Copy Markdown
Owner Author

Reviewed head 9b25c73. One confirmed regression:

  • P2 — shared_preferences/android.rs:L247: Java write failures are cleared and logged, then reported as Ok(()). Previously, with_env propagated the pending exception. Use .or_clear(env)? for commit and the editor mutations. I reproduced this with the actual base/head preference implementations and a Java stub whose commit() throws: base returns Err, head returns Ok.

The PR could be smaller and safer to review. It mixes 1,062 additions and 1,032 deletions across 34 files. Split the existing commits into focused PRs for exception handling, Activity lifetime, class caching, UI dispatch, and Java helper removal. The helper removal can ship independently; it adds substantial handwritten JNI unrelated to fixing Activity references.

Two further recommendations, separate from the confirmed regression:

  • Safety — jni.rs:L585: activity_as_ptr() still exposes a global reference after releasing its lock. Another thread can prune it before use; “must not be kept” cannot prevent that race. Deprecate this accessor and migrate consumers to activity(env).
  • Performance — jni.rs:L698: The UI queue drains every pending task without a limit. Coalesce pending IME position updates so a busy UI thread processes the latest position instead of replaying obsolete ones. This is a code-level recommendation, not a measured slowdown.

Validation covered source review and the JVM reproduction. I did not run an Android build or device tests; TalkBack and Activity transitions deserve targeted verification before merging the lifecycle changes.

Bombatomica64 and others added 4 commits October 9, 2026 12:34
The first pass converted only map_err(to_string). The rest of the core
still cleared exceptions by hand (losing their description), dropped
errors with let _ = and _ => arms, and in two keyboard calls cleared the
exception before or_clear could describe it.

Every call into Java in jni.rs, platform.rs, window.rs, platform_view.rs
and document_picker.rs now goes through or_clear, and errors are returned
or logged. The new or_catch makes an expected exception explicit: the
system chrome falls back to the pre-API-30 flags only on
NoSuchMethodError, where any failure of getInsetsController used to. The
only direct exception_* calls left are in take_exception and or_catch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pt hits

Review of the in-app review port: open_store_page returned early on any
error before startActivity (Uri.parse, the Intent, no Activity), so the
https fallback was never tried, where the removed Java helper caught
everything and fell through. is_available treated every exception as
"not installed", where Java only caught NameNotFoundException.

Only ActivityNotFoundException and NameNotFoundException are now expected
outcomes (or_catch); anything else is an error, and the market:// attempt
falls through to the web page on any failure, logging why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first pass converted map_err(to_string) and inserted or_clear before
swallowed errors. The packages still cleared exceptions by hand in their
Err(_) arms (losing the description), and the shared_preferences editor
writes and commit() logged failures and returned Ok(()) where they used
to return the error.

- Every call into Java in the packages goes through or_clear, or or_catch
  for an expected exception: ActivityNotFoundException when no app takes
  an intent (url_launcher, maps_launcher; any other exception is now an
  error instead of "false"), NoClassDefFoundError for VibrationEffect.
- shared_preferences: writes return their error again, and commit()
  returning false is one; getters and contains_key log what they hide.
- Functions that return a plain value (connectivity, sensors, battery,
  can_vibrate) log the error before falling back to their default.
- battery's sticky broadcast and the default SharedPreferences use the
  application context.

No exception_clear/exception_check is left outside jni.rs's helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…updates

From the review of the JNI rework:

- activity() asked isFinishing and isDestroyed of every registered
  Activity on every call, the hot path of every package. It now checks
  only the newest (popping it if it is gone); older ones are pruned when
  the next Activity registers.
- activity_as_ptr() is deprecated: it is the only way left to hold a raw
  global reference the registry may delete, and nothing in the crate uses
  it any more.
- The system chrome cache was only reset on the android-activity path, so
  a recreated host Activity never had its bars styled. set_host_activity
  resets it.
- run_latest_on_ui_thread replaces a pending task with the same key. The
  IME cursor position and the system chrome use it, so a busy UI thread
  runs the latest update instead of replaying every frame's. Inline runs
  of run_on_ui_thread log a leftover exception like queued ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Bombatomica64

Copy link
Copy Markdown
Owner Author

Thanks to both reviews. I pushed four commits on top (3fc49ca..f77414c).

Exception handling: the sweep is now complete. The first pass only rewrote one pattern.

  • Every call into Java in src/android/*.rs and src/packages/*/android.rs now goes through or_clear. A new or_catch(env, class) makes expected exceptions explicit:
    • ActivityNotFoundException for startActivity in url_launcher, maps_launcher and in_app_review
    • NameNotFoundException in in_app_review is_available
    • NoSuchMethodError for the pre-API-30 system chrome path
    • NoClassDefFoundError for VibrationEffect
  • Any other exception is now an error instead of a quiet false or default.
  • grep exception_clear|exception_check finds only the three calls inside take_exception/or_catch.
  • Functions that return a plain value log the error before falling back to their default.

Confirmed regression, fixed: shared_preferences writes and commit() return their error again, and commit() returning false is an error.

Review points:

  • in_app_review: the market:// attempt falls through to the web page on any failure, and is_available catches only NameNotFoundException. ✔
  • activity_as_ptr() is #[deprecated]. ✔
  • battery's sticky broadcast and get_default_prefs use the application context. ✔
  • vibration no longer leaves a find_class failure pending. ✔
  • The dead clear in sensors is gone. ✔
  • LAST_CHROME_STYLE is reset in set_host_activity. ✔
  • activity() checks only the newest Activity and pops it if it's gone; pruning happens at registration. ✔
  • UI queue: run_latest_on_ui_thread(key, f) replaces a pending task with the same key. The IME position and the system chrome use it. ✔

Not done here, on purpose:

  • PR size and splitting: agreed. The upstream PRs will be cut per concern, and commits 5 and 8 will be split further (8 per package). This fork PR stays one branch so the lab can test it as a whole.
  • accessibility.rs: still uses accesskit's jni 0.21 API, with its own exception behaviour. It belongs with android: expose GPUI's accessibility tree to TalkBack longbridge/gpui-mobile#25.
  • performHapticFeedback: still runs on the calling thread. Moving it to the UI thread would make haptic_feedback asynchronous, which changes its Result.

I'll re-test on the phone with the lab APK (Bombatomica64/Gpui-android#24) before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant