iris/android: fix background-thread redraw crash, add missing INTERNET permission

Two real bugs found bringing up I5's Android transcript client, neither
specific to that screen -- any future caller of Tasks::redraw_handle()
from a background thread would hit the first one.

AndroidRedrawHandle::request_redraw called View::post_frame_callback from
a tokio worker thread; its Java side calls Choreographer.getInstance(),
which throws IllegalStateException unless the *calling* thread already
has a Looper, and a JNI-attached background thread has none. That crashed
the whole process (SIGABRT, unwrap() on a JavaException) the first time a
background fetch asked for a second frame. Fixed by routing through
View::post_delayed(0) instead, Android's own thread-safe way to queue
work onto a View's UI thread, landing on a new
IrisViewPeer::delayed_callback override that drains tasks and renders --
same body as do_frame, now running safely on the UI thread.

iris-android-app's manifest never needed INTERNET before (the tabs demo
makes no network call); its absence read as EPERM ("Operation not
permitted") from UreqTransport::new's connect, not the
ECONNREFUSED/ENETUNREACH a dead server would give.

Full account in RUST.md's I5 box and IRIS.md's Tasks::redraw_handle entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 14:08:40 -04:00
1 parent aa3d11471f
commit bf5087a598
3 files changed
+40 -4

No files matched your search

@@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
no network call and never noticed this was missing. Absent,
UreqTransport::new's connect failed with EPERM (Operation not
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
server would give: a seccomp-level socket denial reads nothing
like a network problem, which is what made it worth a comment. -->
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:label="iris android-view demo" android:label="iris android-view demo"
+17 -4
View File
@@ -169,9 +169,22 @@ impl AndroidRenderer {
/// `Tasks`' redraw handle on Android: a background task finishes on the /// `Tasks`' redraw handle on Android: a background task finishes on the
/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so /// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so
/// asking for a frame means attaching first. `post_frame_callback` needs a /// asking for a frame means attaching first. The global ref is what
/// live `View` reference; the global ref is what survives past the JNI call /// survives past the JNI call that handed the `View` to us.
/// that handed it to us. ///
/// **Goes through `View::post_delayed`, not `post_frame_callback`
/// directly** -- found the hard way (RUST.md's I5 Android integration):
/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`,
/// which throws `IllegalStateException` unless the *calling* thread already
/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio
/// worker thread, even freshly attached to the JVM, has none -- the crash
/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`,
/// aborting the process on the second `redraw.request_redraw()` any
/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)`
/// is the ordinary Android answer to "queue work onto a View's own UI
/// thread from any thread" and needs no Looper of its own; `delayed_callback`
/// below is what that Runnable resolves to on the UI thread, where a real
/// `post_frame_callback` is safe again.
pub struct AndroidRedrawHandle { pub struct AndroidRedrawHandle {
vm: JavaVM, vm: JavaVM,
view: GlobalRef, view: GlobalRef,
@@ -189,6 +202,6 @@ impl RequestRedraw for AndroidRedrawHandle {
return; return;
}; };
let local = env.new_local_ref(&self.view).unwrap(); let local = env.new_local_ref(&self.view).unwrap();
View(local).post_frame_callback(&mut env); View(local).post_delayed(&mut env, 0);
} }
} }
+14
View File
@@ -432,6 +432,20 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
self.render(ctx); self.render(ctx);
} }
/// Where `AndroidRedrawHandle::request_redraw` (`android/render.rs`)
/// actually lands: `View.postDelayed`'s Runnable resolves to this, on
/// the UI thread, which is what makes it safe to call from a background
/// task's own thread when `post_frame_callback`'s `Choreographer`
/// requirement (a `Looper` on the *calling* thread) is not. Same body
/// as `do_frame` -- draining tasks and rendering immediately is a
/// perfectly good answer to "a background fetch has new state," and
/// avoids a second frame-scheduling path to keep in sync with the real
/// one.
fn delayed_callback(&mut self, ctx: &mut CallbackCtx) {
self.drain_tasks();
self.render(ctx);
}
fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> { fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> {
Some(self) Some(self)
} }