From df1290904b1629230fe0c29af6f99f3d2f18c9fe Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 11 Sep 2026 03:44:06 -0400 Subject: [PATCH] Add Iris Android APK tooling --- app/src/android/bench_client.rs | 18 +- app/src/android/mod.rs | 2 +- app/src/android/transcript_client.rs | 10 +- docs/PLAN.md | 27 + iris/Cargo.lock | 81 +- iris/Cargo.toml | 1 + iris/cargo-iris/Cargo.toml | 7 + .../dev/iris/android/IrisView.java | 50 ++ .../dev/iris/android/MainActivity.java | 81 ++ .../android/rustview/RustInputConnection.java | 153 ++++ .../linebender/android/rustview/RustView.java | 287 +++++++ iris/cargo-iris/src/main.rs | 13 + iris/cargo-iris/src/package.rs | 710 ++++++++++++++++++ iris/cargo-iris/tests/fixture/.gitignore | 2 + iris/cargo-iris/tests/fixture/Cargo.toml | 12 + iris/cargo-iris/tests/fixture/src/lib.rs | 97 +++ iris/macro/src/lib.rs | 87 ++- iris/readme.md | 70 ++ iris/src/android/ime.rs | 18 +- iris/src/android/input.rs | 2 +- iris/src/android/mod.rs | 34 +- iris/src/android/render.rs | 20 +- iris/src/android/view.rs | 94 ++- iris/src/lib.rs | 1 + 24 files changed, 1814 insertions(+), 63 deletions(-) create mode 100644 iris/cargo-iris/Cargo.toml create mode 100644 iris/cargo-iris/android-host/dev/iris/android/IrisView.java create mode 100644 iris/cargo-iris/android-host/dev/iris/android/MainActivity.java create mode 100644 iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java create mode 100644 iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java create mode 100644 iris/cargo-iris/src/main.rs create mode 100644 iris/cargo-iris/src/package.rs create mode 100644 iris/cargo-iris/tests/fixture/.gitignore create mode 100644 iris/cargo-iris/tests/fixture/Cargo.toml create mode 100644 iris/cargo-iris/tests/fixture/src/lib.rs diff --git a/app/src/android/bench_client.rs b/app/src/android/bench_client.rs index 3d96dfe..07be916 100644 --- a/app/src/android/bench_client.rs +++ b/app/src/android/bench_client.rs @@ -112,8 +112,8 @@ fn battery_line(samples: &[i32]) -> String { ) } -impl AndroidAppState for BenchClient { - fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self { +impl BenchClient { + pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self { let content = WidgetPtr::new().add(rsc); let loading = placeholder(rsc, "Loading fixture..."); content(rsc).set(loading); @@ -186,16 +186,24 @@ impl AndroidAppState for BenchClient { } client } +} - fn platform_ready(&mut self, _rsc: &mut StdRsc, vm: JavaVM, view: GlobalRef) { +impl AndroidAppState for BenchClient { + type Resources = StdRsc; + + fn platform_ready(&mut self, _rsc: &mut Self::Resources, vm: JavaVM, view: GlobalRef) { self.platform = Some(Arc::new(PlatformHandle::new(vm, view))); } - fn back_pressed(&mut self, _rsc: &mut StdRsc) -> bool { + fn back_pressed(&mut self, _rsc: &mut Self::Resources) -> bool { false } - fn on_insets_changed(&mut self, rsc: &mut StdRsc, insets: iris::android::WindowInsets) { + fn on_insets_changed( + &mut self, + rsc: &mut Self::Resources, + insets: iris::android::WindowInsets, + ) { if insets.top != self.last_top_pad { self.last_top_pad = insets.top; let controls = bench_controls(rsc, insets.top); diff --git a/app/src/android/mod.rs b/app/src/android/mod.rs index 997fcf2..72c9c25 100644 --- a/app/src/android/mod.rs +++ b/app/src/android/mod.rs @@ -36,7 +36,7 @@ extern "system" fn new_view_peer<'local>( view: View<'local>, context: Context<'local>, ) -> jlong { - iris::android::new_peer::(env, view, context) + iris::android::new_peer::(env, view, context, ActiveClient::new) } /// # Safety diff --git a/app/src/android/transcript_client.rs b/app/src/android/transcript_client.rs index fe95797..7e1e5a2 100644 --- a/app/src/android/transcript_client.rs +++ b/app/src/android/transcript_client.rs @@ -89,8 +89,8 @@ fn frame_report_controls(rsc: &mut StdRsc) -> WeakWidget { (report, reset).span(Dir::RIGHT).height(56).add(rsc) } -impl AndroidAppState for TranscriptClient { - fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self { +impl TranscriptClient { + pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self { let content = WidgetPtr::new().add(rsc); let loading = placeholder(rsc, "Loading sessions..."); content(rsc).set(loading); @@ -112,8 +112,12 @@ impl AndroidAppState for TranscriptClient { client.spawn_fetch_sessions(rsc); client } +} - fn back_pressed(&mut self, _rsc: &mut StdRsc) -> bool { +impl AndroidAppState for TranscriptClient { + type Resources = StdRsc; + + fn back_pressed(&mut self, _rsc: &mut Self::Resources) -> bool { // No screen stack of its own -- same "let the activity finish" // answer `iris-android-app`'s tabs `Client` already gives. false diff --git a/docs/PLAN.md b/docs/PLAN.md index 0e6d700..6fe5efa 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -790,6 +790,33 @@ ssh case are one implementation. `--bind ` is an *explicit, logged* override for development, a deliberate flag and never a fallback. +## Iris Android application contract (2026-09-11) + +**An Iris Android application supplies an initialization function and its own +state/resources; Iris supplies the JNI host and APK packager.** +`#[iris::app_init]` marks the factory Android calls when it creates the Iris +view. The macro target-gates the factory and generates the single exported +`JNI_OnLoad` plus the concrete `android-view` registration callback. The +function returns the application state, whose `AndroidAppState::Resources` +associated type selects the resource bundle. `StdRsc` is the default, never a +host requirement; a custom bundle works by implementing the narrow +`AndroidResources` capabilities. + +`cargo-iris` is an installable Cargo subcommand, rather than a script callers +must find inside an Iris checkout. `cargo iris apk` builds the Rust `cdylib` +with cargo-ndk and packages it directly with the installed Android SDK tools: +javac, d8, aapt2, jar, zipalign and apksigner. Gradle is not in the ordinary +path because Iris's fixed Java view host has no Maven/AAR dependency or +variant graph for it to manage. An application that later embeds Iris in a +larger Gradle project can use that project as the packaging authority instead. + +The tool discovers and validates prerequisites but never installs an SDK, +NDK, JDK, system image or emulator. `cargo iris run` requires an explicit +device serial; it never chooses, creates, starts or stops a device. Debug APKs +use Android's conventional debug key. Release signing requires the caller's +explicit keystore, alias and environment-supplied passwords, since Iris must +not create or own an application's permanent update identity. + ## App (`app/`) One Rust crate owns platform-free client logic and Iris widget trees. Android diff --git a/iris/Cargo.lock b/iris/Cargo.lock index f22b561..0c452ee 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -681,6 +681,46 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-iris" +version = "0.1.0" +dependencies = [ + "cargo_metadata", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "cc" version = "1.4.5" @@ -965,7 +1005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1587,6 +1627,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -2406,7 +2452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3030,7 +3076,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3078,6 +3124,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -3115,6 +3165,19 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3325,7 +3388,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3522,7 +3585,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3975,7 +4038,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4713,6 +4776,12 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.3" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index 6c63e9b..06f0fa5 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -44,6 +44,7 @@ harness = false [workspace] members = [ + "cargo-iris", "core", "macro", "rig-input", diff --git a/iris/cargo-iris/Cargo.toml b/iris/cargo-iris/Cargo.toml new file mode 100644 index 0000000..557fea7 --- /dev/null +++ b/iris/cargo-iris/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cargo-iris" +version.workspace = true +edition.workspace = true + +[dependencies] +cargo_metadata = "0.23.1" diff --git a/iris/cargo-iris/android-host/dev/iris/android/IrisView.java b/iris/cargo-iris/android-host/dev/iris/android/IrisView.java new file mode 100644 index 0000000..49829bd --- /dev/null +++ b/iris/cargo-iris/android-host/dev/iris/android/IrisView.java @@ -0,0 +1,50 @@ +package dev.iris.android; + +import android.app.Activity; +import android.content.Context; +import android.view.Gravity; +import android.widget.ScrollView; +import android.widget.TextView; +import org.linebender.android.rustview.RustView; + +public final class IrisView extends RustView { + @Override + protected native long newViewPeer(Context context); + + native void applyWindowInsetsNative( + long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible); + + native void unregisterInsetsNative(long peer); + + public IrisView(Context context) { + super(context); + } + + void applyWindowInsets( + int left, int top, int right, int bottom, int imeBottom, int imeVisible) { + applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible); + } + + @Override + protected void onDetachedFromWindow() { + unregisterInsetsNative(mViewPeer); + super.onDetachedFromWindow(); + } + + void showRendererError(String report) { + Context context = getContext(); + if (!(context instanceof Activity)) { + return; + } + Activity activity = (Activity) context; + TextView text = new TextView(activity); + text.setText(report); + text.setTextIsSelectable(true); + text.setGravity(Gravity.TOP | Gravity.START); + int pad = (int) (16 * activity.getResources().getDisplayMetrics().density); + text.setPadding(pad, pad, pad, pad); + ScrollView scroll = new ScrollView(activity); + scroll.addView(text); + activity.setContentView(scroll); + } +} diff --git a/iris/cargo-iris/android-host/dev/iris/android/MainActivity.java b/iris/cargo-iris/android-host/dev/iris/android/MainActivity.java new file mode 100644 index 0000000..1e63b44 --- /dev/null +++ b/iris/cargo-iris/android-host/dev/iris/android/MainActivity.java @@ -0,0 +1,81 @@ +package dev.iris.android; + +import android.app.Activity; +import android.os.Build; +import android.os.Bundle; +import android.view.WindowInsets; +import android.view.WindowInsetsAnimation; +import android.widget.FrameLayout; +import java.util.List; + +public final class MainActivity extends Activity { + static { + System.loadLibrary("IRIS_NATIVE_LIBRARY"); + } + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + IrisView view = new IrisView(this); + view.setLayoutParams(new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + view.setFocusable(true); + view.setFocusableInTouchMode(true); + FrameLayout layout = new FrameLayout(this); + layout.addView(view); + setContentView(layout); + view.requestFocus(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + drawBehindSystemBars(); + view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback( + WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + @Override + public WindowInsets onProgress( + WindowInsets insets, List running) { + sendInsets(view, insets); + return insets; + } + + @Override + public void onEnd(WindowInsetsAnimation animation) { + WindowInsets settled = view.getRootWindowInsets(); + if (settled != null) { + sendInsets(view, settled); + } + } + }); + } + + view.setOnApplyWindowInsetsListener((v, insets) -> { + sendInsets((IrisView) v, insets); + return insets; + }); + } + + // Android 15 deprecated this API in favor of edge-to-edge enforcement, + // but API 30 through 34 still need it and Iris supports that whole range. + @SuppressWarnings("deprecation") + private void drawBehindSystemBars() { + getWindow().setDecorFitsSystemWindows(false); + } + + // API 29 has no replacement for these four system-window inset getters; + // the API 30 methods cannot run on Iris's supported minimum. + @SuppressWarnings("deprecation") + private static void sendInsets(IrisView view, WindowInsets insets) { + int imeBottom = 0; + int imeVisible = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; + imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0; + } + view.applyWindowInsets( + insets.getSystemWindowInsetLeft(), + insets.getSystemWindowInsetTop(), + insets.getSystemWindowInsetRight(), + insets.getSystemWindowInsetBottom(), + imeBottom, + imeVisible); + } +} diff --git a/iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java b/iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java new file mode 100644 index 0000000..0a3a94d --- /dev/null +++ b/iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java @@ -0,0 +1,153 @@ +package org.linebender.android.rustview; + +import android.os.Bundle; +import android.os.Handler; +import android.view.KeyEvent; +import android.view.inputmethod.CompletionInfo; +import android.view.inputmethod.CorrectionInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputContentInfo; + +class RustInputConnection implements InputConnection { + private final RustView mView; + + RustInputConnection(RustView view) { + mView = view; + } + + private long getViewPeer() { + return mView.mViewPeer; + } + + @Override + public CharSequence getTextBeforeCursor(int n, int flags) { + return mView.getTextBeforeCursorNative(getViewPeer(), n); + } + + @Override + public CharSequence getTextAfterCursor(int n, int flags) { + return mView.getTextAfterCursorNative(getViewPeer(), n); + } + + @Override + public CharSequence getSelectedText(int flags) { + return mView.getSelectedTextNative(getViewPeer()); + } + + @Override + public int getCursorCapsMode(int reqModes) { + return mView.getCursorCapsModeNative(getViewPeer(), reqModes); + } + + @Override + public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { + return null; + } + + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength); + } + + @Override + public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { + return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength); + } + + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition); + } + + @Override + public boolean setComposingRegion(int start, int end) { + return mView.setComposingRegionNative(getViewPeer(), start, end); + } + + @Override + public boolean finishComposingText() { + return mView.finishComposingTextNative(getViewPeer()); + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition); + } + + @Override + public boolean commitCompletion(CompletionInfo text) { + return false; + } + + @Override + public boolean commitCorrection(CorrectionInfo correctionInfo) { + return false; + } + + @Override + public boolean setSelection(int start, int end) { + return mView.setSelectionNative(getViewPeer(), start, end); + } + + @Override + public boolean performEditorAction(int editorAction) { + return mView.performEditorActionNative(getViewPeer(), editorAction); + } + + @Override + public boolean performContextMenuAction(int id) { + return mView.performContextMenuActionNative(getViewPeer(), id); + } + + @Override + public boolean beginBatchEdit() { + return mView.beginBatchEditNative(getViewPeer()); + } + + @Override + public boolean endBatchEdit() { + return mView.endBatchEditNative(getViewPeer()); + } + + @Override + public boolean sendKeyEvent(KeyEvent event) { + return mView.inputConnectionSendKeyEventNative(getViewPeer(), event); + } + + @Override + public boolean clearMetaKeyStates(int states) { + return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states); + } + + @Override + public boolean reportFullscreenMode(boolean enabled) { + return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled); + } + + @Override + public boolean performPrivateCommand(String action, Bundle data) { + return false; + } + + @Override + public boolean requestCursorUpdates(int cursorUpdateMode) { + return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode); + } + + @Override + public Handler getHandler() { + return null; + } + + @Override + public void closeConnection() { + mView.closeInputConnectionNative(getViewPeer()); + } + + @Override + public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) { + return false; + } +} diff --git a/iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java b/iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java new file mode 100644 index 0000000..b7fa4ad --- /dev/null +++ b/iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java @@ -0,0 +1,287 @@ +package org.linebender.android.rustview; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Bundle; +import android.view.Choreographer; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeProvider; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; + +public abstract class RustView extends SurfaceView + implements SurfaceHolder.Callback, Choreographer.FrameCallback { + // Vendored from android-view bec6c62. The only local change is `protected`, + // allowing IrisView to forward insets through this native peer. + protected final long mViewPeer; + final InputMethodManager mInputMethodManager; + + protected abstract long newViewPeer(Context context); + + public RustView(Context context) { + super(context); + mViewPeer = newViewPeer(context); + getHolder().addCallback(this); + mInputMethodManager = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec); + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec); + if (result != null) { + setMeasuredDimension(result[0], result[1]); + } else { + super.onMeasure(widthSpec, heightSpec); + } + } + + private native void onLayoutNative( + long peer, boolean changed, int left, int top, int right, int bottom); + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + onLayoutNative(mViewPeer, changed, left, top, right, bottom); + super.onLayout(changed, left, top, right, bottom); + } + + private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh); + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + onSizeChangedNative(mViewPeer, w, h, oldw, oldh); + super.onSizeChanged(w, h, oldw, oldh); + } + + private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event); + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event); + } + + private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event); + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event); + } + + private native boolean onTrackballEventNative(long peer, MotionEvent event); + + @Override + public boolean onTrackballEvent(MotionEvent event) { + return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event); + } + + private native boolean onTouchEventNative(long peer, MotionEvent event); + + @Override + public boolean onTouchEvent(MotionEvent event) { + return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event); + } + + private native boolean onGenericMotionEventNative(long peer, MotionEvent event); + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event); + } + + private native boolean onHoverEventNative(long peer, MotionEvent event); + + @Override + public boolean onHoverEvent(MotionEvent event) { + return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event); + } + + private native void onFocusChangedNative( + long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect); + + @Override + protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect); + } + + private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus); + + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + onWindowFocusChangedNative(mViewPeer, hasWindowFocus); + } + + private native void onAttachedToWindowNative(long peer); + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + onAttachedToWindowNative(mViewPeer); + } + + private native void onDetachedFromWindowNative(long peer); + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + onDetachedFromWindowNative(mViewPeer); + } + + private native void onWindowVisibilityChangedNative(long peer, int visibility); + + @Override + protected void onWindowVisibilityChanged(int visibility) { + super.onWindowVisibilityChanged(visibility); + onWindowVisibilityChangedNative(mViewPeer, visibility); + } + + private native void surfaceCreatedNative(long peer, SurfaceHolder holder); + + @Override + public void surfaceCreated(SurfaceHolder holder) { + surfaceCreatedNative(mViewPeer, holder); + } + + private native void surfaceChangedNative( + long peer, SurfaceHolder holder, int format, int width, int height); + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + surfaceChangedNative(mViewPeer, holder, format, width, height); + } + + private native void surfaceDestroyedNative(long peer, SurfaceHolder holder); + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + surfaceDestroyedNative(mViewPeer, holder); + } + + void postFrameCallback() { + Choreographer c = Choreographer.getInstance(); + c.removeFrameCallback(this); + c.postFrameCallback(this); + } + + void removeFrameCallback() { + Choreographer.getInstance().removeFrameCallback(this); + } + + private native void doFrameNative(long peer, long frameTimeNanos); + + @Override + public void doFrame(long frameTimeNanos) { + doFrameNative(mViewPeer, frameTimeNanos); + } + + private native void delayedCallbackNative(long peer); + + private final Runnable mDelayedCallback = + new Runnable() { + @Override + public void run() { + delayedCallbackNative(mViewPeer); + } + }; + + boolean postDelayed(long delayMillis) { + return postDelayed(mDelayedCallback, delayMillis); + } + + boolean removeDelayedCallbacks() { + return removeCallbacks(mDelayedCallback); + } + + private native boolean hasAccessibilityNodeProviderNative(long peer); + + private native AccessibilityNodeInfo createAccessibilityNodeInfoNative( + long peer, int virtualViewId); + + private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId); + + private native boolean performAccessibilityActionNative( + long peer, int virtualViewId, int action, Bundle arguments); + + @Override + public AccessibilityNodeProvider getAccessibilityNodeProvider() { + if (!hasAccessibilityNodeProviderNative(mViewPeer)) { + return super.getAccessibilityNodeProvider(); + } + return new AccessibilityNodeProvider() { + @Override + public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { + return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId); + } + + @Override + public AccessibilityNodeInfo findFocus(int focusType) { + return accessibilityFindFocusNative(mViewPeer, focusType); + } + + @Override + public boolean performAction(int virtualViewId, int action, Bundle arguments) { + return performAccessibilityActionNative( + mViewPeer, virtualViewId, action, arguments); + } + }; + } + + private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs); + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) { + return null; + } + return new RustInputConnection(this); + } + + native String getTextBeforeCursorNative(long peer, int n); + + native String getTextAfterCursorNative(long peer, int n); + + native String getSelectedTextNative(long peer); + + native int getCursorCapsModeNative(long peer, int reqModes); + + native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength); + + native boolean deleteSurroundingTextInCodePointsNative( + long peer, int beforeLength, int afterLength); + + native boolean setComposingTextNative(long peer, String text, int newCursorPosition); + + native boolean setComposingRegionNative(long peer, int start, int end); + + native boolean finishComposingTextNative(long peer); + + native boolean commitTextNative(long peer, String text, int newCursorPosition); + + native boolean setSelectionNative(long peer, int start, int end); + + native boolean performEditorActionNative(long peer, int editorAction); + + native boolean performContextMenuActionNative(long peer, int id); + + native boolean beginBatchEditNative(long peer); + + native boolean endBatchEditNative(long peer); + + native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event); + + native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states); + + native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled); + + native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode); + + native void closeInputConnectionNative(long peer); +} diff --git a/iris/cargo-iris/src/main.rs b/iris/cargo-iris/src/main.rs new file mode 100644 index 0000000..fcff0dd --- /dev/null +++ b/iris/cargo-iris/src/main.rs @@ -0,0 +1,13 @@ +mod package; + +use std::{env, process::ExitCode}; + +fn main() -> ExitCode { + match package::run(env::args().skip(1).collect()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("cargo iris: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/iris/cargo-iris/src/package.rs b/iris/cargo-iris/src/package.rs new file mode 100644 index 0000000..5eab202 --- /dev/null +++ b/iris/cargo-iris/src/package.rs @@ -0,0 +1,710 @@ +use cargo_metadata::{CrateType, MetadataCommand, Package}; +use std::{ + env, + ffi::OsStr, + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +const MIN_SDK: u32 = 29; +const ACTIVITY: &str = "dev.iris.android.MainActivity"; + +pub fn run(mut args: Vec) -> Result<(), String> { + if args.first().is_some_and(|arg| arg == "iris") { + args.remove(0); + } + let command = args.first().map(String::as_str).unwrap_or("help"); + if matches!(command, "help" | "--help" | "-h") { + print_help(); + return Ok(()); + } + if !matches!(command, "apk" | "run") { + return Err(format!( + "unknown command {command:?}; run `cargo iris --help`" + )); + } + + let options = Options::parse(&args[1..], command == "run")?; + let built = build(&options)?; + println!("{}", built.apk.display()); + if command == "run" { + install_and_run(&built, options.device.as_deref().unwrap())?; + } + Ok(()) +} + +fn print_help() { + println!( + "Build an Iris library as an installable Android APK.\n\n\ + Usage:\n cargo iris apk [OPTIONS]\n cargo iris run --device SERIAL [OPTIONS]\n\n\ + Options:\n --manifest-path PATH\n --package NAME\n --abi arm64-v8a|x86_64\n --release\n\ + \x20 --application-id ID\n --label TEXT\n --keystore PATH --key-alias ALIAS\n\n\ + Release signing passwords come from IRIS_KEYSTORE_PASSWORD and, when different,\n\ + IRIS_KEY_PASSWORD. Iris uses the Android SDK and emulator/device supplied by you." + ); +} + +#[derive(Default)] +struct Options { + manifest_path: Option, + package: Option, + abi: String, + release: bool, + application_id: Option, + label: Option, + keystore: Option, + key_alias: Option, + device: Option, +} + +impl Options { + fn parse(args: &[String], run: bool) -> Result { + let mut options = Self { + abi: "arm64-v8a".into(), + ..Self::default() + }; + let mut i = 0; + while i < args.len() { + let value = |name: &str, i: &mut usize| -> Result { + *i += 1; + args.get(*i) + .cloned() + .ok_or_else(|| format!("{name} needs a value")) + }; + match args[i].as_str() { + "--manifest-path" => { + options.manifest_path = Some(value("--manifest-path", &mut i)?.into()) + } + "--package" => options.package = Some(value("--package", &mut i)?), + "--abi" => options.abi = value("--abi", &mut i)?, + "--application-id" => { + options.application_id = Some(value("--application-id", &mut i)?) + } + "--label" => options.label = Some(value("--label", &mut i)?), + "--keystore" => options.keystore = Some(value("--keystore", &mut i)?.into()), + "--key-alias" => options.key_alias = Some(value("--key-alias", &mut i)?), + "--device" => options.device = Some(value("--device", &mut i)?), + "--release" => options.release = true, + other => return Err(format!("unknown option {other:?}")), + } + i += 1; + } + if !matches!(options.abi.as_str(), "arm64-v8a" | "x86_64") { + return Err(format!( + "unsupported ABI {:?}; use arm64-v8a or x86_64", + options.abi + )); + } + if run && options.device.is_none() { + return Err( + "`cargo iris run` needs --device SERIAL; Iris never chooses or starts an emulator" + .into(), + ); + } + if options.release && (options.keystore.is_none() || options.key_alias.is_none()) { + return Err("a release APK needs --keystore PATH and --key-alias ALIAS".into()); + } + Ok(options) + } +} + +struct Built { + apk: PathBuf, + application_id: String, + sdk: Sdk, +} + +fn build(options: &Options) -> Result { + let mut metadata = MetadataCommand::new(); + if let Some(path) = &options.manifest_path { + metadata.manifest_path(path); + } + let metadata = metadata + .exec() + .map_err(|error| format!("could not read Cargo metadata: {error}"))?; + let package = select_package( + &metadata.packages, + metadata.root_package(), + options.package.as_deref(), + )?; + let target = package + .targets + .iter() + .find(|target| { + target + .crate_types + .iter() + .any(|kind| kind == &CrateType::CDyLib) + }) + .ok_or_else(|| { + format!( + "package {} has no cdylib target; add `[lib] crate-type = [\"cdylib\", \"rlib\"]` to {}", + package.name, package.manifest_path + ) + })?; + let sdk = Sdk::find()?; + let application_id = options + .application_id + .clone() + .or_else(|| metadata_string(package, "application-id")) + .unwrap_or_else(|| default_application_id(&package.name)); + validate_application_id(&application_id)?; + let label = options + .label + .clone() + .or_else(|| metadata_string(package, "label")) + .unwrap_or_else(|| package.name.to_string()); + let variant = if options.release { "release" } else { "debug" }; + let output = metadata + .target_directory + .as_std_path() + .join("iris-android") + .join(package.name.as_str()) + .join(variant) + .join(&options.abi); + recreate(&output)?; + let native = output.join("native"); + + let mut cargo = Command::new("cargo"); + cargo + .args(["ndk", "-t", &options.abi, "-P", &MIN_SDK.to_string(), "-o"]) + .arg(&native) + .arg("build") + .arg("--lib") + .arg("--manifest-path") + .arg(package.manifest_path.as_std_path()); + if options.release { + cargo.arg("--release"); + } + run_command( + &mut cargo, + "Rust Android library", + "install cargo-ndk with `cargo install cargo-ndk`", + )?; + + let library = native + .join(&options.abi) + .join(format!("lib{}.so", target.name)); + if !library.is_file() { + return Err(format!("cargo-ndk did not produce {}", library.display())); + } + let classes = output.join("classes"); + fs::create_dir_all(&classes).map_err(io_error("create Java output", &classes))?; + let sources = materialize_host(&output, &target.name)?; + let java_files = files_with_extension(&sources, "java")?; + let mut javac = Command::new("javac"); + javac + .args(["--release", "17", "-classpath"]) + .arg(&sdk.android_jar) + .arg("-d") + .arg(&classes) + .args(&java_files); + run_command( + &mut javac, + "Iris Android Java host", + "install a JDK containing javac", + )?; + + let dex = output.join("dex"); + fs::create_dir_all(&dex).map_err(io_error("create DEX output", &dex))?; + let class_files = files_with_extension(&classes, "class")?; + let mut d8 = Command::new(&sdk.d8); + d8.args(["--min-api", &MIN_SDK.to_string(), "--lib"]) + .arg(&sdk.android_jar) + .arg("--output") + .arg(&dex) + .args(&class_files); + if options.release { + d8.arg("--release"); + } else { + d8.arg("--debug"); + } + run_command( + &mut d8, + "Iris Android DEX", + "install Android SDK Build Tools", + )?; + + let manifest = output.join("AndroidManifest.xml"); + fs::write( + &manifest, + manifest_xml(&application_id, &label, &target.name, sdk.api), + ) + .map_err(io_error("write Android manifest", &manifest))?; + let unsigned = output.join("unsigned.apk"); + let mut aapt = Command::new(&sdk.aapt2); + aapt.arg("link") + .arg("-o") + .arg(&unsigned) + .arg("-I") + .arg(&sdk.android_jar) + .arg("--manifest") + .arg(&manifest) + .args([ + "--min-sdk-version", + &MIN_SDK.to_string(), + "--target-sdk-version", + &sdk.api.to_string(), + ]); + run_command( + &mut aapt, + "Android resources", + "install Android SDK Build Tools", + )?; + append_payload( + &unsigned, + &output, + &dex.join("classes.dex"), + &library, + &options.abi, + &target.name, + )?; + + let aligned = output.join("aligned.apk"); + let mut zipalign = Command::new(&sdk.zipalign); + zipalign + .args(["-P", "16", "-f", "4"]) + .arg(&unsigned) + .arg(&aligned); + run_command( + &mut zipalign, + "APK alignment", + "install Android SDK Build Tools", + )?; + let apk = output.join(format!("{}-{variant}.apk", package.name)); + sign(&sdk, options, &aligned, &apk)?; + verify(&sdk, &apk)?; + Ok(Built { + apk, + application_id, + sdk, + }) +} + +fn select_package<'a>( + packages: &'a [Package], + root: Option<&'a Package>, + wanted: Option<&str>, +) -> Result<&'a Package, String> { + if let Some(wanted) = wanted { + return packages + .iter() + .find(|package| package.name == wanted) + .ok_or_else(|| format!("Cargo workspace has no package named {wanted:?}")); + } + root.ok_or_else(|| { + "this is a virtual workspace; select an application with --package NAME".into() + }) +} + +fn metadata_string(package: &Package, key: &str) -> Option { + package + .metadata + .get("iris")? + .get("android")? + .get(key)? + .as_str() + .map(str::to_owned) +} + +fn default_application_id(package: &str) -> String { + let segment: String = package + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + format!("dev.iris.app.{segment}") +} + +fn validate_application_id(id: &str) -> Result<(), String> { + let valid = id.split('.').count() >= 2 + && id.split('.').all(|segment| { + !segment.is_empty() + && segment.as_bytes()[0].is_ascii_alphabetic() + && segment + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_') + }); + if valid { + Ok(()) + } else { + Err(format!( + "application ID {id:?} is invalid; use dot-separated Java identifiers" + )) + } +} + +struct Sdk { + api: u32, + android_jar: PathBuf, + aapt2: PathBuf, + d8: PathBuf, + zipalign: PathBuf, + apksigner: PathBuf, + adb: PathBuf, +} + +impl Sdk { + fn find() -> Result { + let root = env::var_os("ANDROID_HOME") + .or_else(|| env::var_os("ANDROID_SDK_ROOT")) + .map(PathBuf::from) + .ok_or_else(|| "ANDROID_HOME is unset; point it at your Android SDK".to_string())?; + let (api, platform) = newest_numbered(&root.join("platforms"), "android-")?; + let (_, tools) = newest_numbered(&root.join("build-tools"), "")?; + let executable = |name: &str, windows_extension: &str| { + tools.join(if cfg!(windows) { + format!("{name}.{windows_extension}") + } else { + name.to_string() + }) + }; + let sdk = Self { + android_jar: platform.join("android.jar"), + aapt2: executable("aapt2", "exe"), + d8: executable("d8", "bat"), + zipalign: executable("zipalign", "exe"), + apksigner: executable("apksigner", "bat"), + adb: root + .join("platform-tools") + .join(if cfg!(windows) { "adb.exe" } else { "adb" }), + api, + }; + for (name, path) in [ + ("android.jar", &sdk.android_jar), + ("aapt2", &sdk.aapt2), + ("d8", &sdk.d8), + ("zipalign", &sdk.zipalign), + ("apksigner", &sdk.apksigner), + ] { + if !path.is_file() { + return Err(format!( + "Android SDK is missing {name} at {}; install a platform and Build Tools", + path.display() + )); + } + } + Ok(sdk) + } +} + +fn newest_numbered(parent: &Path, prefix: &str) -> Result<(u32, PathBuf), String> { + let entries = fs::read_dir(parent).map_err(|error| { + format!( + "cannot read {}: {error}; install the required Android SDK component", + parent.display() + ) + })?; + entries + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name(); + let version = name + .to_string_lossy() + .strip_prefix(prefix)? + .split('.') + .map(str::parse::) + .collect::, _>>() + .ok()?; + Some((version, entry.path())) + }) + .max_by(|(left, _), (right, _)| left.cmp(right)) + .map(|(version, path)| (version[0], path)) + .ok_or_else(|| { + format!( + "no installed Android SDK component found under {}", + parent.display() + ) + }) +} + +fn materialize_host(output: &Path, library: &str) -> Result { + let root = output.join("java"); + for (relative, contents) in HOST_FILES { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()) + .map_err(io_error("create Java source directory", &path))?; + let contents = if relative.ends_with("MainActivity.java") { + contents.replace("IRIS_NATIVE_LIBRARY", library) + } else { + contents.to_string() + }; + fs::write(&path, contents).map_err(io_error("write Java host source", &path))?; + } + Ok(root) +} + +const HOST_FILES: &[(&str, &str)] = &[ + ( + "dev/iris/android/MainActivity.java", + include_str!("../android-host/dev/iris/android/MainActivity.java"), + ), + ( + "dev/iris/android/IrisView.java", + include_str!("../android-host/dev/iris/android/IrisView.java"), + ), + ( + "org/linebender/android/rustview/RustView.java", + include_str!("../android-host/org/linebender/android/rustview/RustView.java"), + ), + ( + "org/linebender/android/rustview/RustInputConnection.java", + include_str!("../android-host/org/linebender/android/rustview/RustInputConnection.java"), + ), +]; + +fn manifest_xml(application_id: &str, label: &str, library: &str, target_sdk: u32) -> String { + format!( + r#" + + + + + + + + + + + + +"#, + xml_escape(application_id), + xml_escape(label), + xml_escape(library) + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn files_with_extension(root: &Path, extension: &str) -> Result, String> { + fn visit(dir: &Path, extension: &str, output: &mut Vec) -> Result<(), String> { + for entry in fs::read_dir(dir).map_err(io_error("read directory", dir))? { + let path = entry + .map_err(|error| format!("cannot read entry under {}: {error}", dir.display()))? + .path(); + if path.is_dir() { + visit(&path, extension, output)?; + } else if path.extension() == Some(OsStr::new(extension)) { + output.push(path); + } + } + Ok(()) + } + let mut files = Vec::new(); + visit(root, extension, &mut files)?; + files.sort(); + Ok(files) +} + +fn append_payload( + apk: &Path, + output: &Path, + dex: &Path, + library: &Path, + abi: &str, + library_name: &str, +) -> Result<(), String> { + let payload = output.join("payload"); + let native_dir = payload.join("lib").join(abi); + fs::create_dir_all(&native_dir).map_err(io_error("create APK payload", &native_dir))?; + fs::copy(dex, payload.join("classes.dex")).map_err(io_error("stage classes.dex", dex))?; + let native_name = format!("lib{library_name}.so"); + fs::copy(library, native_dir.join(&native_name)) + .map_err(io_error("stage native library", library))?; + + // `jar` is part of the JDK already needed for javac. Storing the native + // library uncompressed lets zipalign give it the 16 KiB page alignment + // required by current Android devices. + let mut jar = Command::new("jar"); + jar.args(["--update", "--file"]) + .arg(apk) + .args(["--no-manifest", "--no-compress", "-C"]) + .arg(&payload) + .arg("classes.dex") + .arg("-C") + .arg(&payload) + .arg("lib"); + run_command( + &mut jar, + "APK native payload", + "install a JDK containing jar", + ) +} + +fn sign(sdk: &Sdk, options: &Options, input: &Path, output: &Path) -> Result<(), String> { + let (keystore, alias, store_password, key_password) = if options.release { + let store = env::var("IRIS_KEYSTORE_PASSWORD") + .map_err(|_| "IRIS_KEYSTORE_PASSWORD is unset for release signing".to_string())?; + let key = env::var("IRIS_KEY_PASSWORD").unwrap_or_else(|_| store.clone()); + ( + options.keystore.clone().unwrap(), + options.key_alias.clone().unwrap(), + store, + key, + ) + } else { + let home = env::var_os("HOME") + .ok_or_else(|| "HOME is unset; cannot locate the Android debug keystore".to_string())?; + let keystore = PathBuf::from(home).join(".android/debug.keystore"); + ensure_debug_keystore(&keystore)?; + ( + keystore, + "androiddebugkey".into(), + "android".into(), + "android".into(), + ) + }; + let mut command = Command::new(&sdk.apksigner); + command + .arg("sign") + .args([ + "--ks-pass", + "env:IRIS_APK_STORE_PASSWORD", + "--key-pass", + "env:IRIS_APK_KEY_PASSWORD", + "--ks-key-alias", + ]) + .arg(alias) + .arg("--ks") + .arg(keystore) + .arg("--out") + .arg(output) + .arg(input) + .env("IRIS_APK_STORE_PASSWORD", store_password) + .env("IRIS_APK_KEY_PASSWORD", key_password); + run_command( + &mut command, + "APK signing", + "check the keystore, alias, and signing passwords", + ) +} + +fn ensure_debug_keystore(path: &Path) -> Result<(), String> { + if path.is_file() { + return Ok(()); + } + fs::create_dir_all(path.parent().unwrap()) + .map_err(io_error("create Android configuration directory", path))?; + let mut keytool = Command::new("keytool"); + keytool.args(["-genkeypair", "-keystore"]).arg(path).args([ + "-storepass", + "android", + "-alias", + "androiddebugkey", + "-keypass", + "android", + "-dname", + "CN=Android Debug,O=Android,C=US", + "-keyalg", + "RSA", + "-keysize", + "2048", + "-validity", + "10000", + ]); + run_command( + &mut keytool, + "Android debug key", + "install a JDK containing keytool", + ) +} + +fn verify(sdk: &Sdk, apk: &Path) -> Result<(), String> { + let mut align = Command::new(&sdk.zipalign); + align.args(["-c", "-P", "16", "4"]).arg(apk); + run_command( + &mut align, + "APK alignment verification", + "this indicates a cargo-iris packaging defect", + )?; + let mut sign = Command::new(&sdk.apksigner); + sign.args(["verify", "--verbose"]).arg(apk); + run_command( + &mut sign, + "APK signature verification", + "this indicates a cargo-iris signing defect", + ) +} + +fn install_and_run(built: &Built, device: &str) -> Result<(), String> { + if !built.sdk.adb.is_file() { + return Err(format!( + "Android SDK is missing adb at {}; install Platform Tools", + built.sdk.adb.display() + )); + } + let mut install = Command::new(&built.sdk.adb); + install + .args(["-s", device, "install", "--no-streaming", "-r"]) + .arg(&built.apk); + run_command( + &mut install, + "APK install", + "check that the selected device is connected and authorized", + )?; + let component = format!("{}/{}", built.application_id, ACTIVITY); + let mut launch = Command::new(&built.sdk.adb); + launch.args(["-s", device, "shell", "am", "start", "-n", &component]); + run_command( + &mut launch, + "APK launch", + "check the package activity in the generated APK", + ) +} + +fn recreate(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_dir_all(path).map_err(io_error("clear prior APK staging directory", path))?; + } + fs::create_dir_all(path).map_err(io_error("create APK staging directory", path)) +} + +fn run_command(command: &mut Command, thing: &str, fix: &str) -> Result<(), String> { + command.stdin(Stdio::null()); + let status = command + .status() + .map_err(|error| format!("could not start {thing}: {error}; {fix}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("{thing} failed with {status}; {fix}")) + } +} + +fn io_error<'a>(action: &'a str, path: &'a Path) -> impl FnOnce(std::io::Error) -> String + 'a { + move |error| format!("cannot {action} {}: {error}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn application_ids_are_validated() { + assert!(validate_application_id("dev.iris.app.demo_2").is_ok()); + assert!(validate_application_id("one").is_err()); + assert!(validate_application_id("dev.2demo").is_err()); + assert!(validate_application_id("dev.iris.bad-name").is_err()); + } + + #[test] + fn manifest_values_are_escaped() { + let manifest = manifest_xml("dev.iris.demo", "A & ", "demo", 37); + assert!(manifest.contains("A & <demo>")); + assert!(manifest.contains("android:minSdkVersion=\"29\"")); + } +} diff --git a/iris/cargo-iris/tests/fixture/.gitignore b/iris/cargo-iris/tests/fixture/.gitignore new file mode 100644 index 0000000..042776a --- /dev/null +++ b/iris/cargo-iris/tests/fixture/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/iris/cargo-iris/tests/fixture/Cargo.toml b/iris/cargo-iris/tests/fixture/Cargo.toml new file mode 100644 index 0000000..79ca0da --- /dev/null +++ b/iris/cargo-iris/tests/fixture/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "iris-apk-fixture" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +iris = { path = "../../.." } + +[workspace] diff --git a/iris/cargo-iris/tests/fixture/src/lib.rs b/iris/cargo-iris/tests/fixture/src/lib.rs new file mode 100644 index 0000000..5706b7a --- /dev/null +++ b/iris/cargo-iris/tests/fixture/src/lib.rs @@ -0,0 +1,97 @@ +use iris::prelude::*; +use std::sync::Arc; + +#[derive(AndroidUiState)] +pub struct State { + ui_state: AndroidUiState, +} + +impl AndroidAppState for State { + type Resources = Resources; +} + +#[iris::app_init] +fn create(mut ui_state: AndroidUiState, rsc: &mut Resources) -> State { + rsc.launches += 1; + rect(PaintId::RED) + .label("Iris APK fixture") + .set_root(rsc, &mut ui_state); + State { ui_state } +} + +pub struct Resources { + ui: Ui, + events: EventManager, + tasks: Tasks, + widget_state: WidgetState, + launches: u32, +} + +impl AndroidResources for Resources { + fn new(redraw: Arc) -> (Self, TaskMsgReceiver) { + let (tasks, receiver) = Tasks::init(redraw); + ( + Self { + ui: Ui::default(), + events: EventManager::default(), + tasks, + widget_state: WidgetState::default(), + launches: 0, + }, + receiver, + ) + } +} + +impl UiRsc for Resources { + fn ui(&self) -> &Ui { + &self.ui + } + + fn ui_mut(&mut self) -> &mut Ui { + &mut self.ui + } + + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + self.widget_state.remove(id); + } +} + +impl HasState for Resources { + type State = State; +} + +impl HasEvents for Resources { + fn events(&self) -> &EventManager { + &self.events + } + + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +impl HasTasks for Resources { + fn tasks_mut(&mut self) -> &mut Tasks { + &mut self.tasks + } +} + +impl HasWidgetState for Resources { + fn widget_state(&self) -> &WidgetState { + &self.widget_state + } + + fn widget_state_mut(&mut self) -> &mut WidgetState { + &mut self.widget_state + } +} diff --git a/iris/macro/src/lib.rs b/iris/macro/src/lib.rs index 07f8d17..3810791 100644 --- a/iris/macro/src/lib.rs +++ b/iris/macro/src/lib.rs @@ -2,13 +2,96 @@ extern crate proc_macro; use proc_macro::TokenStream; use quote::quote; use syn::{ - Attribute, Block, Error, GenericParam, Generics, Ident, ItemStruct, ItemTrait, Signature, - Token, Type, Visibility, + Attribute, Block, Error, FnArg, GenericParam, Generics, Ident, ItemFn, ItemStruct, ItemTrait, + ReturnType, Signature, Token, Type, Visibility, parse::{Parse, ParseStream, Result}, parse_macro_input, parse_quote, spanned::Spanned, }; +/// Marks the factory called when Android creates an Iris view. +/// +/// An attribute is necessary here because the Android loader requires one +/// exported `JNI_OnLoad` symbol and `android-view` requires a plain function +/// pointer monomorphized for the returned application state. The generated +/// linker and JNI glue is Android-gated; the annotated function therefore +/// does not need its own `cfg` attribute. +#[proc_macro_attribute] +pub fn app_init(args: TokenStream, item: TokenStream) -> TokenStream { + if !args.is_empty() { + return Error::new( + proc_macro2::Span::call_site(), + "app_init takes no arguments", + ) + .into_compile_error() + .into(); + } + + let function = parse_macro_input!(item as ItemFn); + let name = &function.sig.ident; + let ReturnType::Type(_, state) = &function.sig.output else { + return Error::new( + function.sig.output.span(), + "an app_init function must return its application state", + ) + .into_compile_error() + .into(); + }; + if function.sig.inputs.len() != 2 + || function + .sig + .inputs + .iter() + .any(|argument| !matches!(argument, FnArg::Typed(_))) + { + return Error::new( + function.sig.inputs.span(), + "an app_init function takes AndroidUiState and &mut State::Resources", + ) + .into_compile_error() + .into(); + } + if function.sig.asyncness.is_some() + || function.sig.constness.is_some() + || matches!(function.sig.safety, syn::Safety::Unsafe(_)) + || !function.sig.generics.params.is_empty() + { + return Error::new( + function.sig.span(), + "an app_init function must be a plain, non-generic synchronous function", + ) + .into_compile_error() + .into(); + } + + quote! { + #[cfg(target_os = "android")] + #function + + #[cfg(target_os = "android")] + mod __iris_android_app { + use super::*; + + extern "system" fn new_view_peer<'local>( + env: ::iris::android::__private::JNIEnv<'local>, + view: ::iris::android::__private::View<'local>, + context: ::iris::android::__private::Context<'local>, + ) -> ::iris::android::__private::JLong { + ::iris::android::new_peer::<#state>(env, view, context, super::#name) + } + + #[unsafe(no_mangle)] + pub unsafe extern "system" fn JNI_OnLoad( + vm: *mut ::iris::android::__private::RawJavaVM, + _: *mut ::core::ffi::c_void, + ) -> ::iris::android::__private::JInt { + unsafe { ::iris::android::__private::on_load(vm, new_view_peer) } + } + } + } + .into() +} + struct Input { attrs: Vec, vis: Visibility, diff --git a/iris/readme.md b/iris/readme.md index 9bd2147..3f17f4d 100644 --- a/iris/readme.md +++ b/iris/readme.md @@ -6,6 +6,76 @@ It's currently designed around using retained data structures (widgets), rather Examples are in `examples`, eg. `cargo run --example tabs`. +## Android applications + +An Android application is a library because Android loads its Rust code as a +native shared library: + +```toml +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +iris = { path = "../iris" } + +[package.metadata.iris.android] +application-id = "com.example.myapp" +label = "My app" +``` + +`#[iris::app_init]` marks the factory called when Android creates the Iris +view. The attribute supplies its own Android target gate and generates the JNI +loader glue. The returned state chooses its resources through +`AndroidAppState::Resources`; `StdRsc` is the standard bundle, not a +requirement. + +```rust +use iris::prelude::*; + +#[derive(AndroidUiState)] +struct State { + ui_state: AndroidUiState, +} + +impl AndroidAppState for State { + type Resources = StdRsc; +} + +#[iris::app_init] +fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> State { + rect(PaintId::RED).set_root(rsc, &mut ui_state); + State { ui_state } +} +``` + +Install the Cargo subcommand from a checkout, then invoke it from the +application's directory: + +```sh +cargo install --path /path/to/iris/cargo-iris +cargo iris apk --abi arm64-v8a +cargo iris run --abi x86_64 --device emulator-5554 +``` + +`cargo iris` packages directly with `cargo-ndk`, `javac`, `d8`, `aapt2`, +`jar`, `zipalign`, and `apksigner`; it does not require Gradle. The caller +provides a JDK, Android SDK and NDK, and any emulator or physical device. Set +`ANDROID_HOME` to the SDK. `run` always requires an explicit device and never +creates or starts one. + +Debug APKs use the standard key at `~/.android/debug.keystore`, creating it +with `keytool` when absent. A release build requires the long-lived signing +identity explicitly: + +```sh +IRIS_KEYSTORE_PASSWORD=... IRIS_KEY_PASSWORD=... \ + cargo iris apk --release --keystore /secure/upload.jks --key-alias upload +``` + +APK staging and output live under +`target/iris-android////`; the command's final +line is the verified APK's absolute path. + Goals, in general order: 1. does what I want it to (text, images, video, animations) 2. very easy to use ignoring ergonomic ref counting diff --git a/iris/src/android/ime.rs b/iris/src/android/ime.rs index b76a523..d1a992f 100644 --- a/iris/src/android/ime.rs +++ b/iris/src/android/ime.rs @@ -31,14 +31,14 @@ fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize { impl IrisViewPeer { fn focus(&self) -> Option> { - (!self.rsc.events.controllers.command_target_blocks_input()) + (!self.rsc.events().controllers.command_target_blocks_input()) .then_some(self.state.android_state().focus) .flatten() } pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) { let Some(focus) = self.focus() else { return }; - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let Some(sel) = text.selection_range() else { return; }; @@ -87,7 +87,7 @@ impl InputConnection for IrisViewPeer { IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION, ); if let Some(focus) = self.focus() { - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let sel = text.selection_range().unwrap_or(0..0); let start = byte_to_utf16(text.text(), sel.start) as i32; let end = byte_to_utf16(text.text(), sel.end) as i32; @@ -112,7 +112,7 @@ impl InputConnection for IrisViewPeer { return None; } let focus = self.focus()?; - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let sel = text.selection_range()?; let end_16 = byte_to_utf16(text.text(), sel.start); let start_16 = end_16.saturating_sub(n as usize); @@ -129,7 +129,7 @@ impl InputConnection for IrisViewPeer { return None; } let focus = self.focus()?; - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let sel = text.selection_range()?; let len_16 = byte_to_utf16(text.text(), text.text().len()); let start_16 = byte_to_utf16(text.text(), sel.end); @@ -140,14 +140,14 @@ impl InputConnection for IrisViewPeer { fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option> { let focus = self.focus()?; - Some(Cow::Owned(self.rsc[focus].selected_text()?)) + Some(Cow::Owned(focus.get(&self.rsc).selected_text()?)) } fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 { let Some(focus) = self.focus() else { return 0; }; - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let Some(caret) = text.caret() else { return 0; }; @@ -164,7 +164,7 @@ impl InputConnection for IrisViewPeer { let Some(focus) = self.focus() else { return false; }; - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let Some(sel) = text.selection_range() else { return false; }; @@ -223,7 +223,7 @@ impl InputConnection for IrisViewPeer { let Some(focus) = self.focus() else { return false; }; - let text = &self.rsc[focus]; + let text = focus.get(&self.rsc); let content = text.text(); let byte = utf16_to_byte(content, end.max(0) as usize); focus.edit(&mut self.rsc).set_cursor_byte(byte); diff --git a/iris/src/android/input.rs b/iris/src/android/input.rs index fd507bf..f6de0da 100644 --- a/iris/src/android/input.rs +++ b/iris/src/android/input.rs @@ -10,7 +10,7 @@ use super::view::AndroidAppState; /// the arrow keys on a physical keyboard) plus whatever `unicode_char` /// reports for a plain key press. Returns whether anything used the event. pub(super) fn on_key<'local, State: AndroidAppState>( - rsc: &mut StdRsc, + rsc: &mut State::Resources, state: &mut State, env: &mut JNIEnv<'local>, key_code: Keycode, diff --git a/iris/src/android/mod.rs b/iris/src/android/mod.rs index d37c7e7..1a437a1 100644 --- a/iris/src/android/mod.rs +++ b/iris/src/android/mod.rs @@ -10,9 +10,41 @@ mod view; pub use insets::Insets; pub use render::AndroidRenderer; pub use view::{ - AndroidAppState, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets, new_peer, + AndroidAppState, AndroidResources, AndroidUiState, HasAndroidUiState, IrisViewPeer, + WindowInsets, new_peer, }; +/// Types used by `#[iris::app_init]`'s generated JNI boundary. +#[doc(hidden)] +pub mod __private { + pub use android_view::{Context, View, jni::JNIEnv}; + pub type JInt = android_view::jni::sys::jint; + pub type JLong = android_view::jni::sys::jlong; + pub type RawJavaVM = android_view::jni::sys::JavaVM; + + /// Registers the fixed view class shipped by `cargo-iris`. + /// + /// # Safety + /// `vm` must be the live VM pointer Android passed to `JNI_OnLoad`. + pub unsafe fn on_load( + vm: *mut RawJavaVM, + new_peer: for<'local> extern "system" fn( + JNIEnv<'local>, + View<'local>, + Context<'local>, + ) -> JLong, + ) -> JInt { + use android_view::{jni::JavaVM, register_view_class}; + + let vm = unsafe { JavaVM::from_raw(vm) }.unwrap(); + let mut env = vm.get_env().unwrap(); + const VIEW_CLASS: &str = "dev/iris/android/IrisView"; + register_view_class(&mut env, VIEW_CLASS, new_peer); + super::register_native_methods(&mut env, VIEW_CLASS); + android_view::jni::sys::JNI_VERSION_1_6 + } +} + /// Registers the extra native methods this backend needs beyond what /// `android_view::register_view_class` covers (window insets -- see /// `insets.rs`'s doc comment for why that one could not ride along on an diff --git a/iris/src/android/render.rs b/iris/src/android/render.rs index f6c20a9..6632df6 100644 --- a/iris/src/android/render.rs +++ b/iris/src/android/render.rs @@ -191,7 +191,25 @@ impl AndroidRenderer { ); let surface_caps = surface.get_capabilities(&adapter); - let formats = iris_core::srgb_surface_format(&surface_caps)?; + let mut formats = iris_core::srgb_surface_format(&surface_caps)?; + let downlevel = adapter.get_downlevel_capabilities(); + if formats.view != formats.surface + && !downlevel + .flags + .contains(DownlevelFlags::SURFACE_VIEW_FORMATS) + { + // GLES implementations are allowed to lack texture-view format + // reinterpretation. Configuring the advertised non-sRGB format + // with an sRGB view then fails validation and leaves the surface + // unusable. Rendering through the advertised format is the only + // viable fallback on those devices. + log::warn!( + "iris renderer: {:?} cannot use an sRGB surface view; rendering through {:?}", + formats.surface, + formats.surface, + ); + formats.view = formats.surface; + } log::info!( "iris renderer: surface={:?}, view={:?}, color_space=Srgb", formats.surface, diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index 7235a68..f149ca9 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -89,8 +89,8 @@ impl AndroidUiState { } } -impl HasRoot> for AndroidUiState { - fn set_root(&mut self, rsc: &mut StdRsc, root: StrongWidget) { +impl HasRoot for AndroidUiState { + fn set_root(&mut self, rsc: &mut Rsc, root: StrongWidget) { self.root = Some(crate::overlay::default_overlay_root(rsc, root)); } } @@ -100,16 +100,41 @@ pub trait HasAndroidUiState: Sized + 'static { fn android_state_mut(&mut self) -> &mut AndroidUiState; } +/// Application state retained for the lifetime of one Android `View`. +/// +/// [`StdRsc`] is the usual [`AndroidResources`] implementation, but the host only +/// requires the capabilities in [`AndroidResources`]. An application may add +/// its own resources by supplying another implementation. pub trait AndroidAppState: HasAndroidUiState { - fn new(ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self; + type Resources: AndroidResources; + #[allow(unused_variables)] - fn back_pressed(&mut self, rsc: &mut StdRsc) -> bool { + fn back_pressed(&mut self, rsc: &mut Self::Resources) -> bool { false } #[allow(unused_variables)] - fn platform_ready(&mut self, rsc: &mut StdRsc, vm: JavaVM, view: GlobalRef) {} + fn platform_ready(&mut self, rsc: &mut Self::Resources, vm: JavaVM, view: GlobalRef) {} #[allow(unused_variables)] - fn on_insets_changed(&mut self, rsc: &mut StdRsc, insets: WindowInsets) {} + fn on_insets_changed(&mut self, rsc: &mut Self::Resources, insets: WindowInsets) {} +} + +/// Resources the Android host needs to draw and dispatch application events. +/// +/// This deliberately names capabilities rather than storage. Custom bundles +/// can embed or replace [`StdRsc`] as long as they implement these traits and +/// create the task receiver paired with their [`Tasks`] value. +pub trait AndroidResources: + HasTasks + HasWidgetState + Sized + 'static +where + State: 'static, +{ + fn new(redraw: Arc) -> (Self, TaskMsgReceiver); +} + +impl AndroidResources for StdRsc { + fn new(redraw: Arc) -> (Self, TaskMsgReceiver) { + StdRsc::new(redraw) + } } /// Widget-facing insets in physical pixels, decoupled from JNI's integer shape. @@ -144,9 +169,9 @@ impl WindowInsets { /// `RustView` instance; `new_peer` (below) builds it and hands the id to /// Java the same way android-view's own demo does. pub struct IrisViewPeer { - pub(super) rsc: StdRsc, + pub(super) rsc: State::Resources, pub(super) state: State, - task_recv: TaskMsgReceiver>, + task_recv: TaskMsgReceiver, /// Converts input and Choreographer timestamps onto one monotonic clock. device_clock: Option, } @@ -169,7 +194,7 @@ impl IrisViewPeer { let ui_state = self.state.android_state_mut(); let cursor = ui_state.cursor.clone(); let old_focus = ui_state.focus; - let render_state = self.rsc.ui.render_state(); + let render_state = self.rsc.ui().render_state(); render_state .get() .run_sensors(&mut self.rsc, &mut self.state, cursor, window_size); @@ -200,7 +225,7 @@ impl IrisViewPeer { let ui_state = self.state.android_state_mut(); ui_state.cursor.end_frame(); - let render_state = self.rsc.ui.render_state(); + let render_state = self.rsc.ui().render_state(); if render_state .get() .needs_redraw(&ui_state.root, self.rsc.widgets()) @@ -298,13 +323,13 @@ impl IrisViewPeer { "render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}", ui_state.root.is_some(), self.rsc.widgets().len(), - self.rsc.ui.render_state().get().active_widgets(), + self.rsc.ui().render_state().get().active_widgets(), ui_state .root .as_ref() .and_then(|r| { self.rsc - .ui + .ui() .render_state() .get() .window_region(r, &self.rsc) @@ -313,7 +338,7 @@ impl IrisViewPeer { ); } let frame_start = Instant::now(); - let animating = self.rsc.ui.tick_animations(now); + let animating = self.rsc.ui_mut().tick_animations(now); if animating { ctx.view.post_frame_callback(&mut ctx.env); } @@ -323,7 +348,7 @@ impl IrisViewPeer { let Some(renderer) = &mut ui_state.renderer else { return; }; - let frame_diagnostics = renderer.update(&mut self.rsc.ui); + let frame_diagnostics = renderer.update(self.rsc.ui_mut()); if renderer.frame_count() <= DIAGNOSTIC_FRAMES { log::info!( "iris frame diagnostics: frame={} masks_resized={} moves_resized={} \ @@ -344,20 +369,20 @@ impl IrisViewPeer { .android_state_mut() .frame_report .record(now, parts, animating); - let render_state = self.rsc.ui.render_state(); + let render_state = self.rsc.ui().render_state(); crate::diagnostics::log_frame(&render_state.get(), now, parts, animating); if crate::diagnostics::trace_enabled() { let ui_state = self.state.android_state(); log::debug!( target: "iris::frame", "render(): after update active={} root_px={:?}", - self.rsc.ui.render_state().get().active_widgets(), + self.rsc.ui().render_state().get().active_widgets(), ui_state .root .as_ref() .and_then(|r| { self.rsc - .ui + .ui() .render_state() .get() .window_region(r, &self.rsc) @@ -368,7 +393,7 @@ impl IrisViewPeer { let ui_state = self.state.android_state_mut(); if let Some(tree_update) = ui_state.access.update( self.rsc.widgets(), - &self.rsc.ui.render_state().get(), + &self.rsc.ui().render_state().get(), &self.rsc, ) { let ui_state = self.state.android_state_mut(); @@ -424,7 +449,7 @@ impl ViewPeer for IrisViewPeer { } return handled; } - if self.rsc.events.controllers.command_target_blocks_input() { + if self.rsc.events().controllers.command_target_blocks_input() { return true; } let handled = super::input::on_key( @@ -542,7 +567,7 @@ impl ViewPeer for IrisViewPeer { let x = event.x(&mut ctx.env); let y = event.y(&mut ctx.env); let access_events = { - let render_handle = self.rsc.ui.render_state(); + let render_handle = self.rsc.ui().render_state(); let render_state = render_handle.get(); let mut source = AndroidAccessSource { widgets: self.rsc.widgets(), @@ -596,15 +621,15 @@ impl ViewPeer for IrisViewPeer { ) { self.drain_tasks(); // The layout canvas and wgpu surface are separate and both use physical pixels. - self.rsc.ui.resize((width as f32, height as f32)); + self.rsc.ui_mut().resize((width as f32, height as f32)); // Resizing preserves GPU resources; recreating a destroyed surface does not. let already_live = self.state.android_state().renderer.is_some(); log::info!( "iris surface: surface_changed {width}x{height} already_live={already_live} \ glyphs_cached={} atlas_pages={}", - self.rsc.ui.text.atlas.glyph_count(), - self.rsc.ui.text.atlas.page_count(), + self.rsc.ui().text.atlas.glyph_count(), + self.rsc.ui().text.atlas.page_count(), ); if already_live { let ui_state = self.state.android_state_mut(); @@ -648,11 +673,11 @@ impl ViewPeer for IrisViewPeer { "iris surface: new renderer built ({:?}), re-uploading textures: \ glyphs={} pages={}", renderer.adapter_backend, - self.rsc.ui.text.atlas.glyph_count(), - self.rsc.ui.text.atlas.page_count(), + self.rsc.ui().text.atlas.glyph_count(), + self.rsc.ui().text.atlas.page_count(), ); - self.rsc.ui.textures.reupload(); - self.rsc.ui.paints.reupload(); + self.rsc.ui_mut().textures.reupload(); + self.rsc.ui_mut().paints.reupload(); self.state.android_state_mut().renderer = Some(renderer); self.render(ctx, Instant::now()); } @@ -673,8 +698,8 @@ impl ViewPeer for IrisViewPeer { log::info!( "iris surface: surface_destroyed, tearing the renderer down \ (glyphs_cached={} atlas_pages={})", - self.rsc.ui.text.atlas.glyph_count(), - self.rsc.ui.text.atlas.page_count(), + self.rsc.ui().text.atlas.glyph_count(), + self.rsc.ui().text.atlas.page_count(), ); self.state.android_state_mut().renderer = None; } @@ -724,7 +749,7 @@ impl AccessibilityNodeProvider for IrisViewPeer { ctx: &mut CallbackCtx<'local>, virtual_view_id: jint, ) -> AccessibilityNodeInfo<'local> { - let render_handle = self.rsc.ui.render_state(); + let render_handle = self.rsc.ui().render_state(); let render_state = render_handle.get(); let mut source = AndroidAccessSource { widgets: self.rsc.widgets(), @@ -745,7 +770,7 @@ impl AccessibilityNodeProvider for IrisViewPeer { ctx: &mut CallbackCtx<'local>, focus_type: jint, ) -> AccessibilityNodeInfo<'local> { - let render_handle = self.rsc.ui.render_state(); + let render_handle = self.rsc.ui().render_state(); let render_state = render_handle.get(); let mut source = AndroidAccessSource { widgets: self.rsc.widgets(), @@ -797,6 +822,7 @@ pub fn new_peer<'local, State: AndroidAppState>( mut env: JNIEnv<'local>, view: View<'local>, context: Context<'local>, + init: fn(AndroidUiState, &mut State::Resources) -> State, ) -> android_view::jni::sys::jlong { // `DisplayMetrics.density` -- physical pixels per dp on this device. // Read once here, at the one point in this file already handed a @@ -810,11 +836,11 @@ pub fn new_peer<'local, State: AndroidAppState>( let vm = env.get_java_vm().unwrap(); let global_view = env.new_global_ref(&view.0).unwrap(); let redraw: Arc = Arc::new(AndroidRedrawHandle::new(vm, global_view)); - let (mut rsc, task_recv) = StdRsc::new(redraw); - rsc.ui.set_density(content_scale); + let (mut rsc, task_recv) = State::Resources::new(redraw); + rsc.ui_mut().set_density(content_scale); let shared = Rc::new(RefCell::new(Shared::default())); let ui_state = AndroidUiState::new(shared.clone(), content_scale); - let mut state = State::new(ui_state, &mut rsc); + let mut state = init(ui_state, &mut rsc); let platform_vm = env.get_java_vm().unwrap(); let platform_view = env.new_global_ref(&view.0).unwrap(); state.platform_ready(&mut rsc, platform_vm, platform_view); diff --git a/iris/src/lib.rs b/iris/src/lib.rs index 93cae5b..cb89a73 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -29,6 +29,7 @@ mod layout_tests; pub use iris_core as core; pub use iris_macro as macros; +pub use iris_macro::app_init; pub mod prelude { use super::*;