1use std::fs;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rustferry_core::AndroidAbi;
5use serde::Deserialize;
6
7use crate::command::external_tool_path_arg;
8use crate::{AndroidError, AndroidToolchain, CommandSpec};
9
10#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct CargoBuildArtifacts {
13 pub native_library: Utf8PathBuf,
15 pub dependency_dex_files: Vec<Utf8PathBuf>,
17 pub build_script_out_dirs: Vec<Utf8PathBuf>,
19}
20
21pub fn cargo_build_command(
27 toolchain: &AndroidToolchain,
28 project_dir: &Utf8Path,
29 cargo_target_dir: &Utf8Path,
30 package_name: &str,
31 abi: AndroidAbi,
32 min_sdk: u32,
33 release: bool,
34) -> Result<CommandSpec, AndroidError> {
35 let linker = toolchain.linker_for(abi, min_sdk)?;
36 let archiver = toolchain.llvm_ar()?;
37 let target = abi.rust_target();
38 let mut command = CommandSpec::new(
39 format!("build Rust cdylib for {}", abi.apk_directory()),
40 toolchain.cargo.clone(),
41 Utf8PathBuf::from(external_tool_path_arg(project_dir)?),
42 );
43 command.args = vec![
44 "build".to_owned(),
45 "--manifest-path".to_owned(),
46 external_tool_path_arg(&project_dir.join("Cargo.toml"))?,
47 "--package".to_owned(),
48 package_name.to_owned(),
49 "--lib".to_owned(),
50 "--target".to_owned(),
51 target.to_owned(),
52 "--target-dir".to_owned(),
53 external_tool_path_arg(cargo_target_dir)?,
54 "--message-format=json-render-diagnostics".to_owned(),
55 ];
56 if release {
57 command.args.push("--release".to_owned());
58 }
59 let cargo_target_key = target.replace('-', "_").to_ascii_uppercase();
60 let cc_target_key = target.replace('-', "_");
61 command.environment.insert(
62 format!("CARGO_TARGET_{cargo_target_key}_LINKER"),
63 external_tool_path_arg(&linker)?,
64 );
65 command.environment.insert(
66 format!("CC_{cc_target_key}"),
67 external_tool_path_arg(&linker)?,
68 );
69 command.environment.insert(
70 format!("AR_{cc_target_key}"),
71 external_tool_path_arg(&archiver)?,
72 );
73 set_android_environment(&mut command, &toolchain.sdk_root, &toolchain.ndk.root)?;
74 Ok(command)
75}
76
77fn set_android_environment(
78 command: &mut CommandSpec,
79 sdk_root: &Utf8Path,
80 ndk_root: &Utf8Path,
81) -> Result<(), AndroidError> {
82 let sdk_root = external_tool_path_arg(sdk_root)?;
83 let ndk_root = external_tool_path_arg(ndk_root)?;
84 for name in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
85 command
86 .environment
87 .insert(name.to_owned(), sdk_root.clone());
88 }
89 for name in ["ANDROID_NDK", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"] {
90 command
91 .environment
92 .insert(name.to_owned(), ndk_root.clone());
93 }
94 Ok(())
95}
96
97pub fn collect_cargo_artifacts(
104 stdout: &[u8],
105 rust_target: &str,
106 library_target_name: &str,
107 cargo_target_dir: &Utf8Path,
108) -> Result<CargoBuildArtifacts, AndroidError> {
109 let target_root = canonical_utf8(cargo_target_dir)?;
110 let text = std::str::from_utf8(stdout)
111 .map_err(|error| AndroidError::CargoOutput(format!("stdout was not UTF-8: {error}")))?;
112 let mut native_candidates = Vec::new();
113 let mut out_dirs = Vec::new();
114 for (line_number, line) in text.lines().enumerate() {
115 if line.trim().is_empty() {
116 continue;
117 }
118 let value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
119 AndroidError::CargoOutput(format!(
120 "line {} was not a Cargo JSON message: {error}",
121 line_number + 1
122 ))
123 })?;
124 let reason = value.get("reason").and_then(serde_json::Value::as_str);
125 match reason {
126 Some("compiler-artifact") => {
127 let message: CompilerArtifact = serde_json::from_value(value).map_err(|error| {
128 AndroidError::CargoOutput(format!("malformed compiler-artifact: {error}"))
129 })?;
130 if message.target.name == library_target_name
131 && (message
132 .target
133 .crate_types
134 .iter()
135 .any(|kind| kind == "cdylib")
136 || message.target.kind.iter().any(|kind| kind == "cdylib"))
137 {
138 native_candidates.extend(
139 message
140 .filenames
141 .into_iter()
142 .filter(|path| path.extension() == Some("so")),
143 );
144 }
145 }
146 Some("build-script-executed") => {
147 let message: BuildScriptExecuted =
148 serde_json::from_value(value).map_err(|error| {
149 AndroidError::CargoOutput(format!(
150 "malformed build-script-executed: {error}"
151 ))
152 })?;
153 if let Some(path) = safe_reported_directory(&message.out_dir, &target_root) {
154 out_dirs.push(path);
155 }
156 }
157 _ => {}
158 }
159 }
160 out_dirs.sort();
161 out_dirs.dedup();
162
163 let native_library = native_candidates
164 .iter()
165 .find(|path| {
166 path.file_name().is_some_and(|name| {
167 name == format!("lib{}.so", library_target_name.replace('-', "_"))
168 })
169 })
170 .or_else(|| native_candidates.first())
171 .cloned()
172 .ok_or_else(|| AndroidError::NativeLibraryMissing {
173 target: rust_target.to_owned(),
174 searched: native_candidates.clone(),
175 })?;
176 let native_library = safe_reported_file(&native_library, &target_root).ok_or_else(|| {
177 AndroidError::NativeLibraryMissing {
178 target: rust_target.to_owned(),
179 searched: native_candidates,
180 }
181 })?;
182
183 let mut dex_files = Vec::new();
184 for out_dir in &out_dirs {
185 collect_extension_files(out_dir, "dex", 10, &mut dex_files);
186 }
187 dex_files.sort();
188 dex_files.dedup();
189 Ok(CargoBuildArtifacts {
190 native_library,
191 dependency_dex_files: dex_files,
192 build_script_out_dirs: out_dirs,
193 })
194}
195
196pub fn collect_explicit_dex_inputs(
202 inputs: &[Utf8PathBuf],
203) -> Result<Vec<Utf8PathBuf>, AndroidError> {
204 let mut files = Vec::new();
205 for input in inputs {
206 if input.is_dir() {
207 collect_dex_and_jar_files(input, 10, &mut files);
208 } else if input.is_file() && matches!(input.extension(), Some("dex" | "jar" | "class")) {
209 files.push(input.clone());
210 } else {
211 return Err(AndroidError::InvalidRequest(format!(
212 "DEX/bridge input is missing or unsupported: {input}"
213 )));
214 }
215 }
216 files.sort();
217 files.dedup();
218 Ok(files)
219}
220
221#[derive(Deserialize)]
222struct CompilerArtifact {
223 target: CargoTarget,
224 filenames: Vec<Utf8PathBuf>,
225}
226
227#[derive(Deserialize)]
228struct CargoTarget {
229 name: String,
230 kind: Vec<String>,
231 crate_types: Vec<String>,
232}
233
234#[derive(Deserialize)]
235struct BuildScriptExecuted {
236 out_dir: Utf8PathBuf,
237}
238
239fn safe_reported_directory(path: &Utf8Path, root: &Utf8Path) -> Option<Utf8PathBuf> {
240 let canonical = canonical_utf8(path).ok()?;
241 (canonical.starts_with(root) && canonical.is_dir()).then_some(canonical)
242}
243
244fn safe_reported_file(path: &Utf8Path, root: &Utf8Path) -> Option<Utf8PathBuf> {
245 let canonical = canonical_utf8(path).ok()?;
246 (canonical.starts_with(root) && canonical.is_file()).then_some(canonical)
247}
248
249fn canonical_utf8(path: &Utf8Path) -> Result<Utf8PathBuf, AndroidError> {
250 let canonical = fs::canonicalize(path)
251 .map_err(|source| crate::error::io_error("canonicalize build path", path, source))?;
252 Utf8PathBuf::from_path_buf(canonical).map_err(AndroidError::NonUtf8Path)
253}
254
255fn collect_extension_files(
256 root: &Utf8Path,
257 extension: &str,
258 depth: usize,
259 output: &mut Vec<Utf8PathBuf>,
260) {
261 if depth == 0 {
262 return;
263 }
264 let Ok(entries) = fs::read_dir(root) else {
265 return;
266 };
267 let mut paths = entries
268 .filter_map(Result::ok)
269 .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
270 .collect::<Vec<_>>();
271 paths.sort();
272 for path in paths {
273 let Ok(metadata) = fs::symlink_metadata(&path) else {
274 continue;
275 };
276 if metadata.file_type().is_symlink() {
277 continue;
278 }
279 if metadata.is_dir() {
280 collect_extension_files(&path, extension, depth - 1, output);
281 } else if path.extension() == Some(extension) {
282 output.push(path);
283 }
284 }
285}
286
287fn collect_dex_and_jar_files(root: &Utf8Path, depth: usize, output: &mut Vec<Utf8PathBuf>) {
288 if depth == 0 {
289 return;
290 }
291 let Ok(entries) = fs::read_dir(root) else {
292 return;
293 };
294 let mut paths = entries
295 .filter_map(Result::ok)
296 .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
297 .collect::<Vec<_>>();
298 paths.sort();
299 for path in paths {
300 let Ok(metadata) = fs::symlink_metadata(&path) else {
301 continue;
302 };
303 if metadata.file_type().is_symlink() {
304 continue;
305 }
306 if metadata.is_dir() {
307 collect_dex_and_jar_files(&path, depth - 1, output);
308 } else if matches!(path.extension(), Some("dex" | "jar" | "class")) {
309 output.push(path);
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use std::fs;
317
318 use tempfile::TempDir;
319
320 use super::*;
321 #[cfg(windows)]
322 use crate::{AndroidBuildTools, AndroidNdk, AndroidPlatform};
323
324 #[cfg(windows)]
325 fn fixture_toolchain(root: &Utf8Path) -> AndroidToolchain {
326 let tool = |name: &str| {
327 let path = root.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
328 fs::write(&path, b"").unwrap();
329 path
330 };
331 let prebuilt = root.join("ndk/toolchains/llvm/prebuilt/test");
332 fs::create_dir_all(prebuilt.join("bin")).unwrap();
333 let clang_suffix = if cfg!(windows) { ".cmd" } else { "" };
334 fs::write(
335 prebuilt
336 .join("bin")
337 .join(format!("aarch64-linux-android26-clang{clang_suffix}")),
338 b"",
339 )
340 .unwrap();
341 fs::write(
342 prebuilt
343 .join("bin")
344 .join(format!("llvm-ar{}", std::env::consts::EXE_SUFFIX)),
345 b"",
346 )
347 .unwrap();
348 AndroidToolchain {
349 sdk_root: root.join("sdk"),
350 platform: AndroidPlatform {
351 sdk_root: root.join("sdk"),
352 api_level: 36,
353 directory: root.join("sdk/platforms/android-36"),
354 android_jar: root.join("sdk/platforms/android-36/android.jar"),
355 },
356 build_tools: AndroidBuildTools {
357 sdk_root: root.join("sdk"),
358 version: "36.0.0".to_owned(),
359 directory: root.join("sdk/build-tools/36.0.0"),
360 aapt2: None,
361 d8: None,
362 zipalign: None,
363 apksigner: None,
364 },
365 ndk: AndroidNdk {
366 root: root.join("ndk"),
367 version: "29.0.0".to_owned(),
368 llvm_prebuilt: Some(prebuilt),
369 },
370 cargo: tool("cargo"),
371 rustc: None,
372 rustup: None,
373 java: None,
374 javac: None,
375 keytool: tool("keytool"),
376 }
377 }
378
379 fn cargo_messages(native: &Utf8Path, out_dir: &Utf8Path) -> String {
380 [
381 serde_json::json!({
382 "reason": "compiler-artifact",
383 "target": {
384 "name": "weather",
385 "kind": ["cdylib"],
386 "crate_types": ["cdylib"]
387 },
388 "filenames": [native.as_str()]
389 }),
390 serde_json::json!({
391 "reason": "build-script-executed",
392 "out_dir": out_dir.as_str()
393 }),
394 ]
395 .into_iter()
396 .map(|message| message.to_string())
397 .collect::<Vec<_>>()
398 .join("\n")
399 + "\n"
400 }
401
402 #[test]
403 fn android_environment_includes_native_dependency_ndk_aliases() {
404 let mut command = CommandSpec::new("test", "cargo", ".");
405 #[cfg(windows)]
406 let (sdk, ndk) = (r"C:\Android\sdk", r"C:\Android\ndk");
407 #[cfg(not(windows))]
408 let (sdk, ndk) = ("/android/sdk", "/android/ndk");
409 set_android_environment(&mut command, Utf8Path::new(sdk), Utf8Path::new(ndk)).unwrap();
410
411 for name in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
412 assert_eq!(command.environment.get(name).map(String::as_str), Some(sdk));
413 }
414 for name in ["ANDROID_NDK", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"] {
415 assert_eq!(command.environment.get(name).map(String::as_str), Some(ndk));
416 }
417 }
418
419 #[cfg(windows)]
420 #[test]
421 fn cargo_build_command_normalizes_windows_verbatim_project_paths() {
422 let temporary = TempDir::new().unwrap();
423 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
424 let command = cargo_build_command(
425 &fixture_toolchain(&root),
426 Utf8Path::new(r"\\?\C:\work\calculator"),
427 Utf8Path::new(r"\\?\C:\work\calculator\target"),
428 "calculator",
429 AndroidAbi::Arm64V8a,
430 26,
431 false,
432 )
433 .unwrap();
434
435 assert_eq!(command.current_dir, Utf8Path::new(r"C:\work\calculator"));
436 assert!(
437 command
438 .args
439 .iter()
440 .chain(command.environment.values())
441 .all(|value| !value.contains(r"\\?\"))
442 );
443 assert!(
444 command
445 .args
446 .contains(&r"C:\work\calculator\target".to_owned())
447 );
448 }
449
450 #[test]
451 fn extracts_cdylib_and_dependency_dex_from_cargo_json() {
452 let temp = TempDir::new().unwrap();
453 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
454 let native = root.join("aarch64-linux-android/debug/libweather.so");
455 let out_dir = root.join("aarch64-linux-android/debug/build/slint/out");
456 let dex = out_dir.join("classes.dex");
457 fs::create_dir_all(native.parent().unwrap()).unwrap();
458 fs::create_dir_all(&out_dir).unwrap();
459 fs::write(&native, b"native").unwrap();
460 fs::write(&dex, b"dex\n035\0").unwrap();
461 let json = cargo_messages(&native, &out_dir);
462 let artifacts =
463 collect_cargo_artifacts(json.as_bytes(), "aarch64-linux-android", "weather", &root)
464 .unwrap();
465 assert_eq!(artifacts.native_library, fs::canonicalize(native).unwrap());
466 assert_eq!(
467 artifacts.dependency_dex_files,
468 vec![fs::canonicalize(dex).unwrap()]
469 );
470 assert_eq!(artifacts.build_script_out_dirs.len(), 1);
471 }
472
473 #[test]
474 fn refuses_out_dirs_outside_the_cargo_target() {
475 let target = TempDir::new().unwrap();
476 let outside = TempDir::new().unwrap();
477 let target = Utf8PathBuf::from_path_buf(target.path().to_owned()).unwrap();
478 let outside = Utf8PathBuf::from_path_buf(outside.path().to_owned()).unwrap();
479 let native = target.join("libweather.so");
480 fs::write(&native, b"native").unwrap();
481 fs::write(outside.join("classes.dex"), b"dex\n035\0").unwrap();
482 let json = cargo_messages(&native, &outside);
483 let artifacts =
484 collect_cargo_artifacts(json.as_bytes(), "aarch64-linux-android", "weather", &target)
485 .unwrap();
486 assert!(artifacts.dependency_dex_files.is_empty());
487 assert!(artifacts.build_script_out_dirs.is_empty());
488 }
489}