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,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\""));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user