Add Iris Android APK tooling

This commit is contained in:
iris committed 2026-09-11 03:44:06 -04:00
1 parent e137f38a5d
commit df1290904b
24 files changed
+1814 -63

No files matched your search

+13 -5
View File
@@ -112,8 +112,8 @@ fn battery_line(samples: &[i32]) -> String {
)
}
impl AndroidAppState for BenchClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc<Self>) -> Self {
impl BenchClient {
pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc<Self>) -> 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<Self>, vm: JavaVM, view: GlobalRef) {
impl AndroidAppState for BenchClient {
type Resources = StdRsc<Self>;
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<Self>) -> bool {
fn back_pressed(&mut self, _rsc: &mut Self::Resources) -> bool {
false
}
fn on_insets_changed(&mut self, rsc: &mut StdRsc<Self>, 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);
+1 -1
View File
@@ -36,7 +36,7 @@ extern "system" fn new_view_peer<'local>(
view: View<'local>,
context: Context<'local>,
) -> jlong {
iris::android::new_peer::<ActiveClient>(env, view, context)
iris::android::new_peer::<ActiveClient>(env, view, context, ActiveClient::new)
}
/// # Safety
+7 -3
View File
@@ -89,8 +89,8 @@ fn frame_report_controls(rsc: &mut StdRsc<TranscriptClient>) -> WeakWidget {
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
}
impl AndroidAppState for TranscriptClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc<Self>) -> Self {
impl TranscriptClient {
pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc<Self>) -> 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<Self>) -> bool {
impl AndroidAppState for TranscriptClient {
type Resources = StdRsc<Self>;
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
+27
View File
@@ -790,6 +790,33 @@ ssh case are one implementation.
`--bind <ip>` 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
+75 -6
View File
@@ -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"
+1
View File
@@ -44,6 +44,7 @@ harness = false
[workspace]
members = [
"cargo-iris",
"core",
"macro",
"rig-input",
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cargo-iris"
version.workspace = true
edition.workspace = true
[dependencies]
cargo_metadata = "0.23.1"
@@ -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);
}
}
@@ -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<WindowInsetsAnimation> 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);
}
}
@@ -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;
}
}
@@ -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);
}
+13
View File
@@ -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
}
}
}
+710
View File
@@ -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<String>) -> 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<PathBuf>,
package: Option<String>,
abi: String,
release: bool,
application_id: Option<String>,
label: Option<String>,
keystore: Option<PathBuf>,
key_alias: Option<String>,
device: Option<String>,
}
impl Options {
fn parse(args: &[String], run: bool) -> Result<Self, String> {
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<String, String> {
*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<Built, String> {
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<String> {
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<Self, String> {
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::<u32>)
.collect::<Result<Vec<_>, _>>()
.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<PathBuf, String> {
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 version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="{}" android:versionCode="1" android:versionName="0.1.0">
<uses-sdk android:minSdkVersion="{MIN_SDK}" android:targetSdkVersion="{target_sdk}" />
<application android:allowBackup="true" android:extractNativeLibs="false" android:label="{}" android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity android:name="{ACTIVITY}" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="{}" />
</activity>
</application>
</manifest>
"#,
xml_escape(application_id),
xml_escape(label),
xml_escape(library)
)
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn files_with_extension(root: &Path, extension: &str) -> Result<Vec<PathBuf>, String> {
fn visit(dir: &Path, extension: &str, output: &mut Vec<PathBuf>) -> 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>", "demo", 37);
assert!(manifest.contains("A &amp; &lt;demo&gt;"));
assert!(manifest.contains("android:minSdkVersion=\"29\""));
}
}
+2
View File
@@ -0,0 +1,2 @@
/Cargo.lock
/target/
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "iris-apk-fixture"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
iris = { path = "../../.." }
[workspace]
+97
View File
@@ -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<Self>,
tasks: Tasks<Self>,
widget_state: WidgetState,
launches: u32,
}
impl AndroidResources<State> for Resources {
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>) {
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> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
impl HasTasks for Resources {
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
&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
}
}
+85 -2
View File
@@ -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<Attribute>,
vis: Visibility,
+70
View File
@@ -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<Self>;
}
#[iris::app_init]
fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc<State>) -> 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/<package>/<debug|release>/<abi>/`; 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
+9 -9
View File
@@ -31,14 +31,14 @@ fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
(!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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option<Cow<'slf, str>> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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);
+1 -1
View File
@@ -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<State>,
rsc: &mut State::Resources,
state: &mut State,
env: &mut JNIEnv<'local>,
key_code: Keycode,
+33 -1
View File
@@ -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
+19 -1
View File
@@ -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,
+60 -34
View File
@@ -89,8 +89,8 @@ impl AndroidUiState {
}
}
impl<State: 'static> HasRoot<StdRsc<State>> for AndroidUiState {
fn set_root(&mut self, rsc: &mut StdRsc<State>, root: StrongWidget) {
impl<Rsc: HasEvents> HasRoot<Rsc> 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>) -> Self;
type Resources: AndroidResources<Self>;
#[allow(unused_variables)]
fn back_pressed(&mut self, rsc: &mut StdRsc<Self>) -> bool {
fn back_pressed(&mut self, rsc: &mut Self::Resources) -> bool {
false
}
#[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut StdRsc<Self>, 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<Self>, 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<State>:
HasTasks<State = State> + HasWidgetState + Sized + 'static
where
State: 'static,
{
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>);
}
impl<State: 'static> AndroidResources<State> for StdRsc<State> {
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>) {
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<State: AndroidAppState> {
pub(super) rsc: StdRsc<State>,
pub(super) rsc: State::Resources,
pub(super) state: State,
task_recv: TaskMsgReceiver<StdRsc<State>>,
task_recv: TaskMsgReceiver<State::Resources>,
/// Converts input and Choreographer timestamps onto one monotonic clock.
device_clock: Option<DeviceClock>,
}
@@ -169,7 +194,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
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<State: AndroidAppState> IrisViewPeer<State> {
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<State: AndroidAppState> IrisViewPeer<State> {
"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<State: AndroidAppState> IrisViewPeer<State> {
);
}
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<State: AndroidAppState> IrisViewPeer<State> {
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<State: AndroidAppState> IrisViewPeer<State> {
.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<State: AndroidAppState> IrisViewPeer<State> {
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
}
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) {
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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
"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<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
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<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
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<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
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<dyn RequestRedraw> = 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);
+1
View File
@@ -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::*;