use std::fs;
use camino::{Utf8Path, Utf8PathBuf};
use rustferry_core::AndroidAbi;
use serde::Deserialize;
use crate::command::external_tool_path_arg;
use crate::{AndroidError, AndroidToolchain, CommandSpec};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CargoBuildArtifacts {
pub native_library: Utf8PathBuf,
pub dependency_dex_files: Vec<Utf8PathBuf>,
pub build_script_out_dirs: Vec<Utf8PathBuf>,
}
pub fn cargo_build_command(
toolchain: &AndroidToolchain,
project_dir: &Utf8Path,
cargo_target_dir: &Utf8Path,
package_name: &str,
abi: AndroidAbi,
min_sdk: u32,
release: bool,
) -> Result<CommandSpec, AndroidError> {
let linker = toolchain.linker_for(abi, min_sdk)?;
let archiver = toolchain.llvm_ar()?;
let target = abi.rust_target();
let mut command = CommandSpec::new(
format!("build Rust cdylib for {}", abi.apk_directory()),
toolchain.cargo.clone(),
Utf8PathBuf::from(external_tool_path_arg(project_dir)?),
);
command.args = vec![
"build".to_owned(),
"--manifest-path".to_owned(),
external_tool_path_arg(&project_dir.join("Cargo.toml"))?,
"--package".to_owned(),
package_name.to_owned(),
"--lib".to_owned(),
"--target".to_owned(),
target.to_owned(),
"--target-dir".to_owned(),
external_tool_path_arg(cargo_target_dir)?,
"--message-format=json-render-diagnostics".to_owned(),
];
if release {
command.args.push("--release".to_owned());
}
let cargo_target_key = target.replace('-', "_").to_ascii_uppercase();
let cc_target_key = target.replace('-', "_");
command.environment.insert(
format!("CARGO_TARGET_{cargo_target_key}_LINKER"),
external_tool_path_arg(&linker)?,
);
command.environment.insert(
format!("CC_{cc_target_key}"),
external_tool_path_arg(&linker)?,
);
command.environment.insert(
format!("AR_{cc_target_key}"),
external_tool_path_arg(&archiver)?,
);
set_android_environment(&mut command, &toolchain.sdk_root, &toolchain.ndk.root)?;
Ok(command)
}
fn set_android_environment(
command: &mut CommandSpec,
sdk_root: &Utf8Path,
ndk_root: &Utf8Path,
) -> Result<(), AndroidError> {
let sdk_root = external_tool_path_arg(sdk_root)?;
let ndk_root = external_tool_path_arg(ndk_root)?;
for name in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
command
.environment
.insert(name.to_owned(), sdk_root.clone());
}
for name in ["ANDROID_NDK", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"] {
command
.environment
.insert(name.to_owned(), ndk_root.clone());
}
Ok(())
}
pub fn collect_cargo_artifacts(
stdout: &[u8],
rust_target: &str,
library_target_name: &str,
cargo_target_dir: &Utf8Path,
) -> Result<CargoBuildArtifacts, AndroidError> {
let target_root = canonical_utf8(cargo_target_dir)?;
let text = std::str::from_utf8(stdout)
.map_err(|error| AndroidError::CargoOutput(format!("stdout was not UTF-8: {error}")))?;
let mut native_candidates = Vec::new();
let mut out_dirs = Vec::new();
for (line_number, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
AndroidError::CargoOutput(format!(
"line {} was not a Cargo JSON message: {error}",
line_number + 1
))
})?;
let reason = value.get("reason").and_then(serde_json::Value::as_str);
match reason {
Some("compiler-artifact") => {
let message: CompilerArtifact = serde_json::from_value(value).map_err(|error| {
AndroidError::CargoOutput(format!("malformed compiler-artifact: {error}"))
})?;
if message.target.name == library_target_name
&& (message
.target
.crate_types
.iter()
.any(|kind| kind == "cdylib")
|| message.target.kind.iter().any(|kind| kind == "cdylib"))
{
native_candidates.extend(
message
.filenames
.into_iter()
.filter(|path| path.extension() == Some("so")),
);
}
}
Some("build-script-executed") => {
let message: BuildScriptExecuted =
serde_json::from_value(value).map_err(|error| {
AndroidError::CargoOutput(format!(
"malformed build-script-executed: {error}"
))
})?;
if let Some(path) = safe_reported_directory(&message.out_dir, &target_root) {
out_dirs.push(path);
}
}
_ => {}
}
}
out_dirs.sort();
out_dirs.dedup();
let native_library = native_candidates
.iter()
.find(|path| {
path.file_name().is_some_and(|name| {
name == format!("lib{}.so", library_target_name.replace('-', "_"))
})
})
.or_else(|| native_candidates.first())
.cloned()
.ok_or_else(|| AndroidError::NativeLibraryMissing {
target: rust_target.to_owned(),
searched: native_candidates.clone(),
})?;
let native_library = safe_reported_file(&native_library, &target_root).ok_or_else(|| {
AndroidError::NativeLibraryMissing {
target: rust_target.to_owned(),
searched: native_candidates,
}
})?;
let mut dex_files = Vec::new();
for out_dir in &out_dirs {
collect_extension_files(out_dir, "dex", 10, &mut dex_files);
}
dex_files.sort();
dex_files.dedup();
Ok(CargoBuildArtifacts {
native_library,
dependency_dex_files: dex_files,
build_script_out_dirs: out_dirs,
})
}
pub fn collect_explicit_dex_inputs(
inputs: &[Utf8PathBuf],
) -> Result<Vec<Utf8PathBuf>, AndroidError> {
let mut files = Vec::new();
for input in inputs {
if input.is_dir() {
collect_dex_and_jar_files(input, 10, &mut files);
} else if input.is_file() && matches!(input.extension(), Some("dex" | "jar" | "class")) {
files.push(input.clone());
} else {
return Err(AndroidError::InvalidRequest(format!(
"DEX/bridge input is missing or unsupported: {input}"
)));
}
}
files.sort();
files.dedup();
Ok(files)
}
#[derive(Deserialize)]
struct CompilerArtifact {
target: CargoTarget,
filenames: Vec<Utf8PathBuf>,
}
#[derive(Deserialize)]
struct CargoTarget {
name: String,
kind: Vec<String>,
crate_types: Vec<String>,
}
#[derive(Deserialize)]
struct BuildScriptExecuted {
out_dir: Utf8PathBuf,
}
fn safe_reported_directory(path: &Utf8Path, root: &Utf8Path) -> Option<Utf8PathBuf> {
let canonical = canonical_utf8(path).ok()?;
(canonical.starts_with(root) && canonical.is_dir()).then_some(canonical)
}
fn safe_reported_file(path: &Utf8Path, root: &Utf8Path) -> Option<Utf8PathBuf> {
let canonical = canonical_utf8(path).ok()?;
(canonical.starts_with(root) && canonical.is_file()).then_some(canonical)
}
fn canonical_utf8(path: &Utf8Path) -> Result<Utf8PathBuf, AndroidError> {
let canonical = fs::canonicalize(path)
.map_err(|source| crate::error::io_error("canonicalize build path", path, source))?;
Utf8PathBuf::from_path_buf(canonical).map_err(AndroidError::NonUtf8Path)
}
fn collect_extension_files(
root: &Utf8Path,
extension: &str,
depth: usize,
output: &mut Vec<Utf8PathBuf>,
) {
if depth == 0 {
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
let mut paths = entries
.filter_map(Result::ok)
.filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
.collect::<Vec<_>>();
paths.sort();
for path in paths {
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
collect_extension_files(&path, extension, depth - 1, output);
} else if path.extension() == Some(extension) {
output.push(path);
}
}
}
fn collect_dex_and_jar_files(root: &Utf8Path, depth: usize, output: &mut Vec<Utf8PathBuf>) {
if depth == 0 {
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
let mut paths = entries
.filter_map(Result::ok)
.filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
.collect::<Vec<_>>();
paths.sort();
for path in paths {
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
collect_dex_and_jar_files(&path, depth - 1, output);
} else if matches!(path.extension(), Some("dex" | "jar" | "class")) {
output.push(path);
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
#[cfg(windows)]
use crate::{AndroidBuildTools, AndroidNdk, AndroidPlatform};
#[cfg(windows)]
fn fixture_toolchain(root: &Utf8Path) -> AndroidToolchain {
let tool = |name: &str| {
let path = root.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
fs::write(&path, b"").unwrap();
path
};
let prebuilt = root.join("ndk/toolchains/llvm/prebuilt/test");
fs::create_dir_all(prebuilt.join("bin")).unwrap();
let clang_suffix = if cfg!(windows) { ".cmd" } else { "" };
fs::write(
prebuilt
.join("bin")
.join(format!("aarch64-linux-android26-clang{clang_suffix}")),
b"",
)
.unwrap();
fs::write(
prebuilt
.join("bin")
.join(format!("llvm-ar{}", std::env::consts::EXE_SUFFIX)),
b"",
)
.unwrap();
AndroidToolchain {
sdk_root: root.join("sdk"),
platform: AndroidPlatform {
sdk_root: root.join("sdk"),
api_level: 36,
directory: root.join("sdk/platforms/android-36"),
android_jar: root.join("sdk/platforms/android-36/android.jar"),
},
build_tools: AndroidBuildTools {
sdk_root: root.join("sdk"),
version: "36.0.0".to_owned(),
directory: root.join("sdk/build-tools/36.0.0"),
aapt2: None,
d8: None,
zipalign: None,
apksigner: None,
},
ndk: AndroidNdk {
root: root.join("ndk"),
version: "29.0.0".to_owned(),
llvm_prebuilt: Some(prebuilt),
},
cargo: tool("cargo"),
rustc: None,
rustup: None,
java: None,
javac: None,
keytool: tool("keytool"),
}
}
fn cargo_messages(native: &Utf8Path, out_dir: &Utf8Path) -> String {
[
serde_json::json!({
"reason": "compiler-artifact",
"target": {
"name": "weather",
"kind": ["cdylib"],
"crate_types": ["cdylib"]
},
"filenames": [native.as_str()]
}),
serde_json::json!({
"reason": "build-script-executed",
"out_dir": out_dir.as_str()
}),
]
.into_iter()
.map(|message| message.to_string())
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
#[test]
fn android_environment_includes_native_dependency_ndk_aliases() {
let mut command = CommandSpec::new("test", "cargo", ".");
#[cfg(windows)]
let (sdk, ndk) = (r"C:\Android\sdk", r"C:\Android\ndk");
#[cfg(not(windows))]
let (sdk, ndk) = ("/android/sdk", "/android/ndk");
set_android_environment(&mut command, Utf8Path::new(sdk), Utf8Path::new(ndk)).unwrap();
for name in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
assert_eq!(command.environment.get(name).map(String::as_str), Some(sdk));
}
for name in ["ANDROID_NDK", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"] {
assert_eq!(command.environment.get(name).map(String::as_str), Some(ndk));
}
}
#[cfg(windows)]
#[test]
fn cargo_build_command_normalizes_windows_verbatim_project_paths() {
let temporary = TempDir::new().unwrap();
let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
let command = cargo_build_command(
&fixture_toolchain(&root),
Utf8Path::new(r"\\?\C:\work\calculator"),
Utf8Path::new(r"\\?\C:\work\calculator\target"),
"calculator",
AndroidAbi::Arm64V8a,
26,
false,
)
.unwrap();
assert_eq!(command.current_dir, Utf8Path::new(r"C:\work\calculator"));
assert!(
command
.args
.iter()
.chain(command.environment.values())
.all(|value| !value.contains(r"\\?\"))
);
assert!(
command
.args
.contains(&r"C:\work\calculator\target".to_owned())
);
}
#[test]
fn extracts_cdylib_and_dependency_dex_from_cargo_json() {
let temp = TempDir::new().unwrap();
let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
let native = root.join("aarch64-linux-android/debug/libweather.so");
let out_dir = root.join("aarch64-linux-android/debug/build/slint/out");
let dex = out_dir.join("classes.dex");
fs::create_dir_all(native.parent().unwrap()).unwrap();
fs::create_dir_all(&out_dir).unwrap();
fs::write(&native, b"native").unwrap();
fs::write(&dex, b"dex\n035\0").unwrap();
let json = cargo_messages(&native, &out_dir);
let artifacts =
collect_cargo_artifacts(json.as_bytes(), "aarch64-linux-android", "weather", &root)
.unwrap();
assert_eq!(artifacts.native_library, fs::canonicalize(native).unwrap());
assert_eq!(
artifacts.dependency_dex_files,
vec![fs::canonicalize(dex).unwrap()]
);
assert_eq!(artifacts.build_script_out_dirs.len(), 1);
}
#[test]
fn refuses_out_dirs_outside_the_cargo_target() {
let target = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = Utf8PathBuf::from_path_buf(target.path().to_owned()).unwrap();
let outside = Utf8PathBuf::from_path_buf(outside.path().to_owned()).unwrap();
let native = target.join("libweather.so");
fs::write(&native, b"native").unwrap();
fs::write(outside.join("classes.dex"), b"dex\n035\0").unwrap();
let json = cargo_messages(&native, &outside);
let artifacts =
collect_cargo_artifacts(json.as_bytes(), "aarch64-linux-android", "weather", &target)
.unwrap();
assert!(artifacts.dependency_dex_files.is_empty());
assert!(artifacts.build_script_out_dirs.is_empty());
}
}