Add Iris Android APK tooling

This commit is contained in:
iris committed 2026-09-11 03:44:06 -04:00
1 parent 3246f397b5
commit 37f956707e
20 files changed
+1766 -54

No files matched your search

+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
}
}