Add Iris Android APK tooling
This commit is contained in:
1 parent
3246f397b5
commit
37f956707e
20 files changed
+1766
-54
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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 & <demo>"));
|
||||
assert!(manifest.contains("android:minSdkVersion=\"29\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/Cargo.lock
|
||||
/target/
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "iris-apk-fixture"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
iris = { path = "../../.." }
|
||||
|
||||
[workspace]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user