1use std::path::{Path, PathBuf};
2
3use color_eyre::eyre::{self, bail};
4use smol::fs;
5use target_lexicon::{Aarch64Architecture, Architecture, Triple};
6
7use crate::{
8 android::{
9 backend::AndroidBackend,
10 device::AndroidDevice,
11 toolchain::{AndroidNdk, AndroidSdk, AndroidToolchain},
12 },
13 build::{BuildOptions, RustBuild},
14 device::Artifact,
15 platform::{PackageOptions, Platform},
16 project::Project,
17 utils::{copy_file, run_command},
18};
19
20fn validate_android_package_name(package: &str) -> eyre::Result<()> {
21 if package.is_empty() {
22 bail!("Android package name is empty (set `[package].bundle_identifier` in `Water.toml`).");
23 }
24
25 if package.contains('-') {
26 bail!(
27 "Invalid Android package name: '{package}' (hyphens are not allowed). \
28Set `[package].bundle_identifier` in `Water.toml` to a valid Java package name (e.g. replace '-' with '_')."
29 );
30 }
31
32 for segment in package.split('.') {
33 if segment.is_empty() {
34 bail!("Invalid Android package name: '{package}' (empty segment).");
35 }
36
37 let mut chars = segment.chars();
38 let Some(first) = chars.next() else {
39 bail!("Invalid Android package name: '{package}' (empty segment).");
40 };
41
42 if !(first.is_ascii_alphabetic() || first == '_') {
43 bail!(
44 "Invalid Android package name: '{package}' (segment '{segment}' must start with a letter or underscore)."
45 );
46 }
47
48 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
49 bail!(
50 "Invalid Android package name: '{package}' (segment '{segment}' contains invalid characters)."
51 );
52 }
53 }
54
55 Ok(())
56}
57
58fn ndk_host_tag() -> &'static str {
60 use target_lexicon::{Architecture, OperatingSystem, Triple};
61
62 let host = Triple::host();
63
64 match (&host.operating_system, &host.architecture) {
66 (OperatingSystem::Darwin(_), Architecture::Aarch64(_) | _) => "darwin-x86_64", (OperatingSystem::Windows, _) => "windows-x86_64",
68 (OperatingSystem::Linux, _) => "linux-x86_64",
70 _ => unimplemented!(),
71 }
72}
73
74fn ndk_linker_path(ndk_path: &Path, abi: &str) -> PathBuf {
76 let target = match abi {
77 "arm64-v8a" => "aarch64-linux-android",
78 "x86_64" => "x86_64-linux-android",
79 "armeabi-v7a" => "armv7a-linux-androideabi",
80 "x86" => "i686-linux-android",
81 _ => unimplemented!(),
82 };
83
84 let api_level = 24;
86
87 ndk_path
88 .join("toolchains/llvm/prebuilt")
89 .join(ndk_host_tag())
90 .join("bin")
91 .join(format!("{target}{api_level}-clang"))
92}
93
94fn ndk_ar_path(ndk_path: &Path) -> PathBuf {
96 ndk_path
97 .join("toolchains/llvm/prebuilt")
98 .join(ndk_host_tag())
99 .join("bin/llvm-ar")
100}
101
102fn create_android_toolchain_wrapper(ndk_path: &Path, abi: &str) -> eyre::Result<PathBuf> {
108 use std::io::Write;
109
110 let wrapper_dir = std::env::temp_dir().join("waterui-cmake-toolchains");
112 std::fs::create_dir_all(&wrapper_dir)?;
113
114 let wrapper_path = wrapper_dir.join(format!("android-{abi}.cmake"));
115 let ndk_toolchain = ndk_path.join("build/cmake/android.toolchain.cmake");
116
117 let content = format!(
118 r#"# Auto-generated wrapper toolchain for WaterUI Android builds
119# Sets ANDROID_ABI before including the NDK toolchain to fix cmake-rs cross-compilation
120set(ANDROID_ABI "{abi}")
121set(ANDROID_PLATFORM "android-24")
122include("{ndk_toolchain}")
123"#,
124 abi = abi,
125 ndk_toolchain = ndk_toolchain.display()
126 );
127
128 let mut file = std::fs::File::create(&wrapper_path)?;
129 file.write_all(content.as_bytes())?;
130
131 Ok(wrapper_path)
132}
133
134fn ndk_cxx_path(ndk_path: &Path, abi: &str) -> PathBuf {
136 let target = match abi {
137 "arm64-v8a" => "aarch64-linux-android",
138 "x86_64" => "x86_64-linux-android",
139 "armeabi-v7a" => "armv7a-linux-androideabi",
140 "x86" => "i686-linux-android",
141 _ => unimplemented!(),
142 };
143
144 let api_level = 24;
146
147 ndk_path
148 .join("toolchains/llvm/prebuilt")
149 .join(ndk_host_tag())
150 .join("bin")
151 .join(format!("{target}{api_level}-clang++"))
152}
153
154#[derive(Debug, Clone)]
156pub struct AndroidPlatform {
157 architecture: Architecture,
158}
159
160impl AndroidPlatform {
161 #[must_use]
163 pub const fn new(architecture: Architecture) -> Self {
164 Self { architecture }
165 }
166
167 #[must_use]
169 pub const fn arm64() -> Self {
170 Self {
171 architecture: Architecture::Aarch64(Aarch64Architecture::Aarch64),
172 }
173 }
174
175 #[must_use]
177 pub const fn x86_64() -> Self {
178 Self {
179 architecture: Architecture::X86_64,
180 }
181 }
182
183 #[must_use]
185 pub const fn abi(&self) -> &'static str {
186 match self.architecture {
187 Architecture::Aarch64(_) => "arm64-v8a",
188 Architecture::X86_64 => "x86_64",
189 Architecture::Arm(_) => "armeabi-v7a",
190 Architecture::X86_32(_) => "x86",
191 _ => unimplemented!(),
192 }
193 }
194
195 #[must_use]
197 pub fn from_abi(abi: &str) -> Self {
198 let architecture = match abi {
199 "arm64-v8a" => Architecture::Aarch64(Aarch64Architecture::Aarch64),
200 "x86_64" => Architecture::X86_64,
201 "armeabi-v7a" => Architecture::Arm(target_lexicon::ArmArchitecture::Armv7),
202 "x86" => Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
203 _ => unimplemented!(),
204 };
205 Self { architecture }
206 }
207}
208
209pub const ALL_ABIS: &[&str] = &["arm64-v8a", "x86_64", "armeabi-v7a", "x86"];
211
212impl AndroidPlatform {
213 #[must_use]
215 pub fn all() -> Vec<Self> {
216 ALL_ABIS.iter().map(|abi| Self::from_abi(abi)).collect()
217 }
218
219 pub async fn clean_jni_libs(project: &Project) -> eyre::Result<()> {
224 let jni_libs_dir = project
225 .backend_path::<AndroidBackend>()
226 .join("app/src/main/jniLibs");
227
228 if jni_libs_dir.exists() {
229 fs::remove_dir_all(&jni_libs_dir).await?;
230 }
231 Ok(())
232 }
233
234 pub async fn package_with_abis(
246 project: &Project,
247 options: PackageOptions,
248 abis: &[&str],
249 ) -> eyre::Result<Artifact> {
250 validate_android_package_name(project.bundle_identifier())?;
251
252 let backend_path = project.backend_path::<AndroidBackend>();
253 let gradlew = backend_path.join(if cfg!(windows) {
254 "gradlew.bat"
255 } else {
256 "gradlew"
257 });
258
259 let (command_name, path) = if options.is_distribution() && !options.is_debug() {
260 (
261 "bundleRelease",
262 backend_path.join("app/build/outputs/bundle/release/app-release.aab"),
263 )
264 } else if !options.is_distribution() && !options.is_debug() {
265 (
266 "assembleRelease",
267 backend_path.join("app/build/outputs/apk/release/app-release.apk"),
268 )
269 } else if !options.is_distribution() && options.is_debug() {
270 (
271 "assembleDebug",
272 backend_path.join("app/build/outputs/apk/debug/app-debug.apk"),
273 )
274 } else if options.is_distribution() && options.is_debug() {
275 (
276 "bundleDebug",
277 backend_path.join("app/build/outputs/bundle/debug/app-debug.aab"),
278 )
279 } else {
280 unreachable!()
281 };
282
283 let abis_str = abis.join(",");
285
286 let output = smol::process::Command::new(gradlew.to_str().unwrap())
287 .args([
288 command_name,
289 "--project-dir",
290 backend_path.to_str().unwrap(),
291 ])
292 .env("WATERUI_SKIP_RUST_BUILD", "1")
293 .env("WATERUI_ANDROID_ABIS", &abis_str)
294 .output()
295 .await?;
296
297 if !output.status.success() {
298 let stderr = String::from_utf8_lossy(&output.stderr);
299 let stdout = String::from_utf8_lossy(&output.stdout);
300 bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
301 }
302
303 Ok(Artifact::new(project.bundle_identifier(), path))
304 }
305
306 pub async fn list_avds() -> eyre::Result<Vec<String>> {
311 let emulator_path =
312 AndroidSdk::emulator_path().ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
313
314 let output = smol::process::Command::new(&emulator_path)
315 .arg("-list-avds")
316 .output()
317 .await?;
318
319 let stdout = String::from_utf8_lossy(&output.stdout);
320 let avds: Vec<String> = stdout
321 .lines()
322 .filter(|line| !line.is_empty())
323 .map(String::from)
324 .collect();
325
326 Ok(avds)
327 }
328}
329
330impl Platform for AndroidPlatform {
331 type Device = AndroidDevice;
332 type Toolchain = AndroidToolchain;
333
334 async fn scan(&self) -> eyre::Result<Vec<Self::Device>> {
335 let adb = AndroidSdk::adb_path()
336 .ok_or_else(|| eyre::eyre!("Android SDK not found or adb not installed"))?;
337
338 let output = run_command(adb.to_str().unwrap(), ["devices"]).await?;
340
341 let mut devices = Vec::new();
342
343 for line in output.lines().skip(1) {
344 let parts: Vec<&str> = line.split_whitespace().collect();
345 if parts.len() >= 2 && parts[1] == "device" {
346 let identifier = parts[0].to_string();
347
348 let abi = run_command(
350 adb.to_str().unwrap(),
351 ["-s", &identifier, "shell", "getprop", "ro.product.cpu.abi"],
352 )
353 .await
354 .map_or_else(|_| "arm64-v8a".to_string(), |abi| abi.trim().to_string());
355
356 devices.push(AndroidDevice::new(identifier, abi));
357 }
358 }
359
360 Ok(devices)
361 }
362
363 fn toolchain(&self) -> Self::Toolchain {
364 AndroidToolchain::default()
365 }
366
367 async fn clean(&self, project: &Project) -> eyre::Result<()> {
368 let backend_path = project.backend_path::<AndroidBackend>();
369 let gradlew = backend_path.join(if cfg!(windows) {
370 "gradlew.bat"
371 } else {
372 "gradlew"
373 });
374
375 if !gradlew.exists() {
376 return Ok(());
378 }
379
380 run_command(
381 gradlew.to_str().unwrap(),
382 ["clean", "--project-dir", backend_path.to_str().unwrap()],
383 )
384 .await?;
385
386 Ok(())
387 }
388
389 async fn build(
390 &self,
391 project: &Project,
392 options: BuildOptions,
393 ) -> eyre::Result<std::path::PathBuf> {
394 let ndk_path = AndroidNdk::detect_path().ok_or_else(|| {
396 eyre::eyre!("Android NDK not found. Please install it via Android Studio.")
397 })?;
398
399 let linker = ndk_linker_path(&ndk_path, self.abi());
401 let ar = ndk_ar_path(&ndk_path);
402 let cxx = ndk_cxx_path(&ndk_path, self.abi());
403
404 let target_upper = self.triple().to_string().replace('-', "_").to_uppercase();
406
407 let build = RustBuild::new(project.root(), self.triple(), options.is_hot_reload());
409
410 unsafe {
413 std::env::set_var(format!("CARGO_TARGET_{target_upper}_LINKER"), &linker);
415 std::env::set_var(format!("CARGO_TARGET_{target_upper}_AR"), &ar);
416
417 let target_underscore = self.triple().to_string().replace('-', "_");
419 std::env::set_var(format!("CC_{target_underscore}"), &linker);
420 std::env::set_var(format!("CXX_{target_underscore}"), &cxx);
421 std::env::set_var(format!("AR_{target_underscore}"), &ar);
422
423 std::env::set_var("ANDROID_NDK", &ndk_path);
426 std::env::set_var("ANDROID_NDK_HOME", &ndk_path);
427 std::env::set_var("ANDROID_NDK_ROOT", &ndk_path);
428
429 let android_abi = self.abi();
434 let wrapper_toolchain = create_android_toolchain_wrapper(&ndk_path, android_abi)?;
435
436 std::env::set_var("CMAKE_TOOLCHAIN_FILE", &wrapper_toolchain);
437 std::env::set_var(
438 format!("CMAKE_TOOLCHAIN_FILE_{target_underscore}"),
439 &wrapper_toolchain,
440 );
441
442 std::env::set_var("ANDROID_ABI", android_abi);
444 std::env::set_var("ANDROID_PLATFORM", "android-24");
445
446 if which::which("ninja").is_ok() {
449 std::env::set_var("CMAKE_GENERATOR", "Ninja");
450 }
451 }
452
453 let lib_dir = build.build_lib(options.is_release()).await?;
454
455 let lib_name = project.crate_name().replace('-', "_");
457 let source_lib = lib_dir.join(format!("lib{lib_name}.so"));
458
459 if !source_lib.exists() {
460 bail!(
461 "Rust shared library not found at {}. Did the build succeed?",
462 source_lib.display()
463 );
464 }
465
466 let output_dir = options.output_dir().map_or_else(
468 || {
469 project
470 .backend_path::<AndroidBackend>()
471 .join("app/src/main/jniLibs")
472 .join(self.abi())
473 },
474 std::path::Path::to_path_buf,
475 );
476 fs::create_dir_all(&output_dir).await?;
477
478 let dest_lib = output_dir.join("libwaterui_app.so");
480 copy_file(&source_lib, &dest_lib).await?;
481
482 Ok(lib_dir)
483 }
484
485 fn triple(&self) -> Triple {
486 Triple {
487 architecture: self.architecture,
488 vendor: target_lexicon::Vendor::Unknown,
489 operating_system: target_lexicon::OperatingSystem::Linux,
490 environment: target_lexicon::Environment::Android,
491 binary_format: target_lexicon::BinaryFormat::Elf,
492 }
493 }
494
495 async fn package(
496 &self,
497 project: &Project,
498 options: PackageOptions,
499 ) -> color_eyre::eyre::Result<Artifact> {
500 let backend_path = project.backend_path::<AndroidBackend>();
501 let gradlew = backend_path.join(if cfg!(windows) {
502 "gradlew.bat"
503 } else {
504 "gradlew"
505 });
506
507 let (command_name, path) = if options.is_distribution() && !options.is_debug() {
508 (
509 "bundleRelease",
510 backend_path.join("app/build/outputs/bundle/release/app-release.aab"),
511 )
512 } else if !options.is_distribution() && !options.is_debug() {
513 (
514 "assembleRelease",
515 backend_path.join("app/build/outputs/apk/release/app-release.apk"),
516 )
517 } else if !options.is_distribution() && options.is_debug() {
518 (
519 "assembleDebug",
520 backend_path.join("app/build/outputs/apk/debug/app-debug.apk"),
521 )
522 } else if options.is_distribution() && options.is_debug() {
523 (
524 "bundleDebug",
525 backend_path.join("app/build/outputs/bundle/debug/app-debug.aab"),
526 )
527 } else {
528 unreachable!()
529 };
530
531 let output = smol::process::Command::new(gradlew.to_str().unwrap())
537 .args([
538 command_name,
539 "--project-dir",
540 backend_path.to_str().unwrap(),
541 ])
542 .env("WATERUI_SKIP_RUST_BUILD", "1")
543 .env("WATERUI_ANDROID_ABIS", self.abi())
544 .output()
545 .await?;
546
547 if !output.status.success() {
548 let stderr = String::from_utf8_lossy(&output.stderr);
549 let stdout = String::from_utf8_lossy(&output.stdout);
550 bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
551 }
552
553 Ok(Artifact::new(project.bundle_identifier(), path))
554 }
555}