use std::path::{Path, PathBuf};
use color_eyre::eyre::{self, bail};
use smol::fs;
use target_lexicon::{Aarch64Architecture, Architecture, Triple};
use crate::{
android::{
backend::AndroidBackend,
device::AndroidDevice,
toolchain::{AndroidNdk, AndroidSdk, AndroidToolchain},
},
build::{BuildOptions, RustBuild},
device::Artifact,
platform::{PackageOptions, Platform},
project::Project,
utils::{copy_file, run_command},
};
fn validate_android_package_name(package: &str) -> eyre::Result<()> {
if package.is_empty() {
bail!("Android package name is empty (set `[package].bundle_identifier` in `Water.toml`).");
}
if package.contains('-') {
bail!(
"Invalid Android package name: '{package}' (hyphens are not allowed). \
Set `[package].bundle_identifier` in `Water.toml` to a valid Java package name (e.g. replace '-' with '_')."
);
}
for segment in package.split('.') {
if segment.is_empty() {
bail!("Invalid Android package name: '{package}' (empty segment).");
}
let mut chars = segment.chars();
let Some(first) = chars.next() else {
bail!("Invalid Android package name: '{package}' (empty segment).");
};
if !(first.is_ascii_alphabetic() || first == '_') {
bail!(
"Invalid Android package name: '{package}' (segment '{segment}' must start with a letter or underscore)."
);
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
bail!(
"Invalid Android package name: '{package}' (segment '{segment}' contains invalid characters)."
);
}
}
Ok(())
}
fn ndk_host_tag() -> &'static str {
use target_lexicon::{Architecture, OperatingSystem, Triple};
let host = Triple::host();
match (&host.operating_system, &host.architecture) {
(OperatingSystem::Darwin(_), Architecture::Aarch64(_) | _) => "darwin-x86_64", (OperatingSystem::Windows, _) => "windows-x86_64",
(OperatingSystem::Linux, _) => "linux-x86_64",
_ => unimplemented!(),
}
}
fn ndk_linker_path(ndk_path: &Path, abi: &str) -> PathBuf {
let target = match abi {
"arm64-v8a" => "aarch64-linux-android",
"x86_64" => "x86_64-linux-android",
"armeabi-v7a" => "armv7a-linux-androideabi",
"x86" => "i686-linux-android",
_ => unimplemented!(),
};
let api_level = 24;
ndk_path
.join("toolchains/llvm/prebuilt")
.join(ndk_host_tag())
.join("bin")
.join(format!("{target}{api_level}-clang"))
}
fn ndk_ar_path(ndk_path: &Path) -> PathBuf {
ndk_path
.join("toolchains/llvm/prebuilt")
.join(ndk_host_tag())
.join("bin/llvm-ar")
}
fn create_android_toolchain_wrapper(ndk_path: &Path, abi: &str) -> eyre::Result<PathBuf> {
use std::io::Write;
let wrapper_dir = std::env::temp_dir().join("waterui-cmake-toolchains");
std::fs::create_dir_all(&wrapper_dir)?;
let wrapper_path = wrapper_dir.join(format!("android-{abi}.cmake"));
let ndk_toolchain = ndk_path.join("build/cmake/android.toolchain.cmake");
let content = format!(
r#"# Auto-generated wrapper toolchain for WaterUI Android builds
# Sets ANDROID_ABI before including the NDK toolchain to fix cmake-rs cross-compilation
set(ANDROID_ABI "{abi}")
set(ANDROID_PLATFORM "android-24")
include("{ndk_toolchain}")
"#,
abi = abi,
ndk_toolchain = ndk_toolchain.display()
);
let mut file = std::fs::File::create(&wrapper_path)?;
file.write_all(content.as_bytes())?;
Ok(wrapper_path)
}
fn ndk_cxx_path(ndk_path: &Path, abi: &str) -> PathBuf {
let target = match abi {
"arm64-v8a" => "aarch64-linux-android",
"x86_64" => "x86_64-linux-android",
"armeabi-v7a" => "armv7a-linux-androideabi",
"x86" => "i686-linux-android",
_ => unimplemented!(),
};
let api_level = 24;
ndk_path
.join("toolchains/llvm/prebuilt")
.join(ndk_host_tag())
.join("bin")
.join(format!("{target}{api_level}-clang++"))
}
#[derive(Debug, Clone)]
pub struct AndroidPlatform {
architecture: Architecture,
}
impl AndroidPlatform {
#[must_use]
pub const fn new(architecture: Architecture) -> Self {
Self { architecture }
}
#[must_use]
pub const fn arm64() -> Self {
Self {
architecture: Architecture::Aarch64(Aarch64Architecture::Aarch64),
}
}
#[must_use]
pub const fn x86_64() -> Self {
Self {
architecture: Architecture::X86_64,
}
}
#[must_use]
pub const fn abi(&self) -> &'static str {
match self.architecture {
Architecture::Aarch64(_) => "arm64-v8a",
Architecture::X86_64 => "x86_64",
Architecture::Arm(_) => "armeabi-v7a",
Architecture::X86_32(_) => "x86",
_ => unimplemented!(),
}
}
#[must_use]
pub fn from_abi(abi: &str) -> Self {
let architecture = match abi {
"arm64-v8a" => Architecture::Aarch64(Aarch64Architecture::Aarch64),
"x86_64" => Architecture::X86_64,
"armeabi-v7a" => Architecture::Arm(target_lexicon::ArmArchitecture::Armv7),
"x86" => Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
_ => unimplemented!(),
};
Self { architecture }
}
}
pub const ALL_ABIS: &[&str] = &["arm64-v8a", "x86_64", "armeabi-v7a", "x86"];
impl AndroidPlatform {
#[must_use]
pub fn all() -> Vec<Self> {
ALL_ABIS.iter().map(|abi| Self::from_abi(abi)).collect()
}
pub async fn clean_jni_libs(project: &Project) -> eyre::Result<()> {
let jni_libs_dir = project
.backend_path::<AndroidBackend>()
.join("app/src/main/jniLibs");
if jni_libs_dir.exists() {
fs::remove_dir_all(&jni_libs_dir).await?;
}
Ok(())
}
pub async fn package_with_abis(
project: &Project,
options: PackageOptions,
abis: &[&str],
) -> eyre::Result<Artifact> {
validate_android_package_name(project.bundle_identifier())?;
let backend_path = project.backend_path::<AndroidBackend>();
let gradlew = backend_path.join(if cfg!(windows) {
"gradlew.bat"
} else {
"gradlew"
});
let (command_name, path) = if options.is_distribution() && !options.is_debug() {
(
"bundleRelease",
backend_path.join("app/build/outputs/bundle/release/app-release.aab"),
)
} else if !options.is_distribution() && !options.is_debug() {
(
"assembleRelease",
backend_path.join("app/build/outputs/apk/release/app-release.apk"),
)
} else if !options.is_distribution() && options.is_debug() {
(
"assembleDebug",
backend_path.join("app/build/outputs/apk/debug/app-debug.apk"),
)
} else if options.is_distribution() && options.is_debug() {
(
"bundleDebug",
backend_path.join("app/build/outputs/bundle/debug/app-debug.aab"),
)
} else {
unreachable!()
};
let abis_str = abis.join(",");
let output = smol::process::Command::new(gradlew.to_str().unwrap())
.args([
command_name,
"--project-dir",
backend_path.to_str().unwrap(),
])
.env("WATERUI_SKIP_RUST_BUILD", "1")
.env("WATERUI_ANDROID_ABIS", &abis_str)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
}
Ok(Artifact::new(project.bundle_identifier(), path))
}
pub async fn list_avds() -> eyre::Result<Vec<String>> {
let emulator_path =
AndroidSdk::emulator_path().ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
let output = smol::process::Command::new(&emulator_path)
.arg("-list-avds")
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let avds: Vec<String> = stdout
.lines()
.filter(|line| !line.is_empty())
.map(String::from)
.collect();
Ok(avds)
}
}
impl Platform for AndroidPlatform {
type Device = AndroidDevice;
type Toolchain = AndroidToolchain;
async fn scan(&self) -> eyre::Result<Vec<Self::Device>> {
let adb = AndroidSdk::adb_path()
.ok_or_else(|| eyre::eyre!("Android SDK not found or adb not installed"))?;
let output = run_command(adb.to_str().unwrap(), ["devices"]).await?;
let mut devices = Vec::new();
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1] == "device" {
let identifier = parts[0].to_string();
let abi = run_command(
adb.to_str().unwrap(),
["-s", &identifier, "shell", "getprop", "ro.product.cpu.abi"],
)
.await
.map_or_else(|_| "arm64-v8a".to_string(), |abi| abi.trim().to_string());
devices.push(AndroidDevice::new(identifier, abi));
}
}
Ok(devices)
}
fn toolchain(&self) -> Self::Toolchain {
AndroidToolchain::default()
}
async fn clean(&self, project: &Project) -> eyre::Result<()> {
let backend_path = project.backend_path::<AndroidBackend>();
let gradlew = backend_path.join(if cfg!(windows) {
"gradlew.bat"
} else {
"gradlew"
});
if !gradlew.exists() {
return Ok(());
}
run_command(
gradlew.to_str().unwrap(),
["clean", "--project-dir", backend_path.to_str().unwrap()],
)
.await?;
Ok(())
}
async fn build(
&self,
project: &Project,
options: BuildOptions,
) -> eyre::Result<std::path::PathBuf> {
let ndk_path = AndroidNdk::detect_path().ok_or_else(|| {
eyre::eyre!("Android NDK not found. Please install it via Android Studio.")
})?;
let linker = ndk_linker_path(&ndk_path, self.abi());
let ar = ndk_ar_path(&ndk_path);
let cxx = ndk_cxx_path(&ndk_path, self.abi());
let target_upper = self.triple().to_string().replace('-', "_").to_uppercase();
let build = RustBuild::new(project.root(), self.triple(), options.is_hot_reload());
unsafe {
std::env::set_var(format!("CARGO_TARGET_{target_upper}_LINKER"), &linker);
std::env::set_var(format!("CARGO_TARGET_{target_upper}_AR"), &ar);
let target_underscore = self.triple().to_string().replace('-', "_");
std::env::set_var(format!("CC_{target_underscore}"), &linker);
std::env::set_var(format!("CXX_{target_underscore}"), &cxx);
std::env::set_var(format!("AR_{target_underscore}"), &ar);
std::env::set_var("ANDROID_NDK", &ndk_path);
std::env::set_var("ANDROID_NDK_HOME", &ndk_path);
std::env::set_var("ANDROID_NDK_ROOT", &ndk_path);
let android_abi = self.abi();
let wrapper_toolchain = create_android_toolchain_wrapper(&ndk_path, android_abi)?;
std::env::set_var("CMAKE_TOOLCHAIN_FILE", &wrapper_toolchain);
std::env::set_var(
format!("CMAKE_TOOLCHAIN_FILE_{target_underscore}"),
&wrapper_toolchain,
);
std::env::set_var("ANDROID_ABI", android_abi);
std::env::set_var("ANDROID_PLATFORM", "android-24");
if which::which("ninja").is_ok() {
std::env::set_var("CMAKE_GENERATOR", "Ninja");
}
}
let lib_dir = build.build_lib(options.is_release()).await?;
let lib_name = project.crate_name().replace('-', "_");
let source_lib = lib_dir.join(format!("lib{lib_name}.so"));
if !source_lib.exists() {
bail!(
"Rust shared library not found at {}. Did the build succeed?",
source_lib.display()
);
}
let output_dir = options.output_dir().map_or_else(
|| {
project
.backend_path::<AndroidBackend>()
.join("app/src/main/jniLibs")
.join(self.abi())
},
std::path::Path::to_path_buf,
);
fs::create_dir_all(&output_dir).await?;
let dest_lib = output_dir.join("libwaterui_app.so");
copy_file(&source_lib, &dest_lib).await?;
Ok(lib_dir)
}
fn triple(&self) -> Triple {
Triple {
architecture: self.architecture,
vendor: target_lexicon::Vendor::Unknown,
operating_system: target_lexicon::OperatingSystem::Linux,
environment: target_lexicon::Environment::Android,
binary_format: target_lexicon::BinaryFormat::Elf,
}
}
async fn package(
&self,
project: &Project,
options: PackageOptions,
) -> color_eyre::eyre::Result<Artifact> {
let backend_path = project.backend_path::<AndroidBackend>();
let gradlew = backend_path.join(if cfg!(windows) {
"gradlew.bat"
} else {
"gradlew"
});
let (command_name, path) = if options.is_distribution() && !options.is_debug() {
(
"bundleRelease",
backend_path.join("app/build/outputs/bundle/release/app-release.aab"),
)
} else if !options.is_distribution() && !options.is_debug() {
(
"assembleRelease",
backend_path.join("app/build/outputs/apk/release/app-release.apk"),
)
} else if !options.is_distribution() && options.is_debug() {
(
"assembleDebug",
backend_path.join("app/build/outputs/apk/debug/app-debug.apk"),
)
} else if options.is_distribution() && options.is_debug() {
(
"bundleDebug",
backend_path.join("app/build/outputs/bundle/debug/app-debug.aab"),
)
} else {
unreachable!()
};
let output = smol::process::Command::new(gradlew.to_str().unwrap())
.args([
command_name,
"--project-dir",
backend_path.to_str().unwrap(),
])
.env("WATERUI_SKIP_RUST_BUILD", "1")
.env("WATERUI_ANDROID_ABIS", self.abi())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
}
Ok(Artifact::new(project.bundle_identifier(), path))
}
}