use anyhow::{anyhow, Context, Result};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use whisker_config::Config;
use whisker_plugin::{FileEntry, MetaDataEntry};
use crate::compose::{EnabledTargets, Engine};
use crate::fingerprint;
use crate::render::{escape_xml, render};
const APP_BUILD_GRADLE_KTS: &str = include_str!("templates/android/app/build.gradle.kts");
const APP_MANIFEST_XML: &str = include_str!("templates/android/app/src/main/AndroidManifest.xml");
const MAIN_ACTIVITY_KT: &str =
include_str!("templates/android/app/src/main/kotlin/MainActivity.kt");
const APPLICATION_KT: &str = include_str!("templates/android/app/src/main/kotlin/Application.kt");
const ROOT_BUILD_GRADLE_KTS: &str = include_str!("templates/android/build.gradle.kts");
const SETTINGS_GRADLE_KTS: &str = include_str!("templates/android/settings.gradle.kts");
const GRADLE_PROPERTIES: &str = include_str!("templates/android/gradle.properties");
const GRADLEW: &str = include_str!("templates/android/gradlew");
const GRADLEW_BAT: &str = include_str!("templates/android/gradlew.bat");
const GRADLE_WRAPPER_PROPERTIES: &str =
include_str!("templates/android/gradle/wrapper/gradle-wrapper.properties");
const GRADLE_WRAPPER_JAR: &[u8] =
include_bytes!("templates/android/gradle/wrapper/gradle-wrapper.jar");
#[derive(Debug, Clone, serde::Serialize)]
pub struct AndroidInputs {
pub app_name: String,
pub version: String,
pub build_number: u32,
pub application_id: String,
pub min_sdk: u32,
pub target_sdk: u32,
pub rust_lib_name: String,
pub whisker_workspace_path: PathBuf,
pub whisker_user_package: String,
pub whisker_sdk_version: String,
pub whisker_gradle_plugin_version: String,
pub whisker_maven_url: String,
pub lynx_maven_url: String,
#[serde(default)]
pub extra_permissions: Vec<String>,
#[serde(default)]
pub extra_meta_data: Vec<MetaDataEntry>,
#[serde(default)]
pub extra_gradle_plugins: Vec<String>,
#[serde(default)]
pub extra_gradle_dependencies: Vec<String>,
#[serde(default)]
pub extra_files: BTreeMap<PathBuf, FileEntry>,
pub template_version: u32,
}
pub fn sync(out_dir: &Path, inputs: &AndroidInputs) -> Result<bool> {
let new_fp = fingerprint::fingerprint(
serde_json::to_vec(inputs)
.context("serialize AndroidInputs for fingerprint")?
.as_slice(),
);
let fp_path = out_dir.join(".whisker-fingerprint");
if let Ok(existing) = std::fs::read_to_string(&fp_path) {
if existing.trim() == new_fp {
return Ok(false);
}
}
write_files(out_dir, inputs).context("write Android project files")?;
std::fs::write(&fp_path, &new_fp)
.with_context(|| format!("write fingerprint {}", fp_path.display()))?;
Ok(true)
}
pub(crate) fn template_vars(inputs: &AndroidInputs) -> HashMap<&'static str, String> {
let mut v = HashMap::new();
v.insert("app_name", inputs.app_name.clone());
v.insert("version", inputs.version.clone());
v.insert("build_number", inputs.build_number.to_string());
v.insert("android_application_id", inputs.application_id.clone());
v.insert(
"android_application_class",
application_class_name(&inputs.app_name),
);
v.insert("android_min_sdk", inputs.min_sdk.to_string());
v.insert("android_target_sdk", inputs.target_sdk.to_string());
v.insert("android_project_name", project_name(&inputs.app_name));
v.insert("rust_lib_name", inputs.rust_lib_name.clone());
v.insert(
"whisker_workspace_path",
inputs.whisker_workspace_path.display().to_string(),
);
v.insert("whisker_user_package", inputs.whisker_user_package.clone());
v.insert("whisker_sdk_version", inputs.whisker_sdk_version.clone());
v.insert(
"whisker_gradle_plugin_version",
inputs.whisker_gradle_plugin_version.clone(),
);
v.insert("whisker_maven_url", inputs.whisker_maven_url.clone());
v.insert("lynx_maven_url", inputs.lynx_maven_url.clone());
v.insert(
"extra_uses_permissions",
render_extra_permissions(&inputs.extra_permissions),
);
v.insert(
"extra_application_meta_data",
render_extra_meta_data(&inputs.extra_meta_data),
);
v.insert(
"extra_gradle_plugins",
render_extra_gradle_plugins(&inputs.extra_gradle_plugins),
);
v.insert(
"extra_gradle_dependencies",
render_extra_gradle_dependencies(&inputs.extra_gradle_dependencies),
);
v
}
fn render_extra_gradle_plugins(entries: &[String]) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = String::new();
for entry in entries {
if entry.contains('(') {
out.push_str(&format!(" {entry}\n"));
} else {
out.push_str(&format!(" id(\"{entry}\")\n"));
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn render_extra_gradle_dependencies(entries: &[String]) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = String::new();
for entry in entries {
out.push_str(&format!(" {entry}\n"));
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn render_extra_permissions(perms: &[String]) -> String {
if perms.is_empty() {
return String::new();
}
let mut seen = std::collections::BTreeSet::new();
let mut out = String::new();
for p in perms {
if seen.insert(p.as_str()) {
out.push_str(&format!(
" <uses-permission android:name=\"{}\" />\n",
escape_xml(p),
));
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn render_extra_meta_data(entries: &[MetaDataEntry]) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = String::new();
for e in entries {
out.push_str(&format!(
" <meta-data android:name=\"{}\" android:value=\"{}\" />\n",
escape_xml(&e.name),
escape_xml(&e.value),
));
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn application_class_name(app_name: &str) -> String {
let cleaned: String = app_name
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
if cleaned.is_empty() {
return "WhiskerApp_Application".into();
}
format!("{cleaned}Application")
}
fn project_name(app_name: &str) -> String {
let mut out = String::new();
for (i, c) in app_name.chars().enumerate() {
if c.is_ascii_uppercase() && i > 0 {
out.push('-');
}
out.extend(c.to_lowercase());
}
if out.is_empty() {
out.push_str("whisker-app");
}
format!("{out}-android")
}
fn application_id_to_path(application_id: &str) -> PathBuf {
application_id
.split('.')
.filter(|s| !s.is_empty())
.fold(PathBuf::new(), |acc, seg| acc.join(seg))
}
fn write_files(out_dir: &Path, inputs: &AndroidInputs) -> Result<()> {
let vars = template_vars(inputs);
clean_managed_tree(out_dir).context("clean previous gen tree")?;
let kotlin_pkg = out_dir
.join("app/src/main/kotlin")
.join(application_id_to_path(&inputs.application_id));
let app_class_filename = format!("{}.kt", application_class_name(&inputs.app_name));
let text_files: &[(PathBuf, &str)] = &[
(out_dir.join("app/build.gradle.kts"), APP_BUILD_GRADLE_KTS),
(
out_dir.join("app/src/main/AndroidManifest.xml"),
APP_MANIFEST_XML,
),
(kotlin_pkg.join("MainActivity.kt"), MAIN_ACTIVITY_KT),
(kotlin_pkg.join(&app_class_filename), APPLICATION_KT),
(out_dir.join("build.gradle.kts"), ROOT_BUILD_GRADLE_KTS),
(out_dir.join("settings.gradle.kts"), SETTINGS_GRADLE_KTS),
(out_dir.join("gradle.properties"), GRADLE_PROPERTIES),
(
out_dir.join("gradle/wrapper/gradle-wrapper.properties"),
GRADLE_WRAPPER_PROPERTIES,
),
];
for (path, template) in text_files {
let rendered =
render(template, &vars).with_context(|| format!("render {}", path.display()))?;
write_file(path, rendered.as_bytes(), false)?;
}
write_file(&out_dir.join("gradlew"), GRADLEW.as_bytes(), true)?;
write_file(&out_dir.join("gradlew.bat"), GRADLEW_BAT.as_bytes(), false)?;
write_file(
&out_dir.join("gradle/wrapper/gradle-wrapper.jar"),
GRADLE_WRAPPER_JAR,
false,
)?;
for (rel, entry) in &inputs.extra_files {
crate::render::validate_extra_file_path(rel).with_context(|| {
format!(
"extra_files entry `{}` (Android plugin contribution)",
rel.display(),
)
})?;
let abs = out_dir.join(rel);
let executable = entry.mode.map(|m| m & 0o100 != 0).unwrap_or(false);
let bytes = entry
.to_bytes()
.with_context(|| format!("decode extra_files entry `{}` contents", rel.display()))?;
write_file(&abs, &bytes, executable)?;
}
Ok(())
}
fn clean_managed_tree(out_dir: &Path) -> Result<()> {
if !out_dir.exists() {
return Ok(());
}
let keep = ["app/build", ".gradle", "app/src/main/jniLibs"];
for entry in
std::fs::read_dir(out_dir).with_context(|| format!("read_dir {}", out_dir.display()))?
{
let entry = entry?;
let rel = entry
.path()
.strip_prefix(out_dir)
.map(|p| p.to_path_buf())
.ok();
if let Some(rel) = rel {
if keep.iter().any(|k| rel == Path::new(k)) {
continue;
}
}
if entry.file_name() == "app" && entry.path().is_dir() {
clean_under_app(&entry.path())?;
continue;
}
if entry.file_name() == ".whisker-fingerprint" {
continue;
}
remove_path(&entry.path())?;
}
Ok(())
}
fn clean_under_app(app_dir: &Path) -> Result<()> {
for entry in
std::fs::read_dir(app_dir).with_context(|| format!("read_dir {}", app_dir.display()))?
{
let entry = entry?;
if entry.file_name() == "build" {
continue;
}
if entry.path().is_dir() && entry.file_name() == "src" {
clean_under_src(&entry.path())?;
continue;
}
remove_path(&entry.path())?;
}
Ok(())
}
fn clean_under_src(src_dir: &Path) -> Result<()> {
for entry in
std::fs::read_dir(src_dir).with_context(|| format!("read_dir {}", src_dir.display()))?
{
let entry = entry?;
if entry.path().is_dir() && entry.file_name() == "main" {
clean_under_main(&entry.path())?;
continue;
}
remove_path(&entry.path())?;
}
Ok(())
}
fn clean_under_main(main_dir: &Path) -> Result<()> {
for entry in
std::fs::read_dir(main_dir).with_context(|| format!("read_dir {}", main_dir.display()))?
{
let entry = entry?;
if entry.file_name() == "jniLibs" {
continue;
}
remove_path(&entry.path())?;
}
Ok(())
}
fn remove_path(p: &Path) -> Result<()> {
if p.is_dir() {
std::fs::remove_dir_all(p).with_context(|| format!("rm -rf {}", p.display()))
} else {
std::fs::remove_file(p).with_context(|| format!("rm {}", p.display()))
}
}
fn write_file(path: &Path, bytes: &[u8], executable: bool) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir -p {}", parent.display()))?;
}
std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
if executable {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)?;
}
#[cfg(not(unix))]
let _ = executable;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn inputs_from(
app_config: &Config,
rust_lib_name: String,
whisker_workspace_path: PathBuf,
whisker_user_package: String,
whisker_sdk_version: String,
whisker_gradle_plugin_version: String,
whisker_maven_url: String,
lynx_maven_url: String,
) -> Result<AndroidInputs> {
inputs_from_with_engine(
&Engine::with_builtins(),
app_config,
rust_lib_name,
whisker_workspace_path,
whisker_user_package,
whisker_sdk_version,
whisker_gradle_plugin_version,
whisker_maven_url,
lynx_maven_url,
)
}
#[allow(clippy::too_many_arguments)]
pub fn inputs_from_with_engine(
engine: &Engine,
app_config: &Config,
rust_lib_name: String,
whisker_workspace_path: PathBuf,
whisker_user_package: String,
whisker_sdk_version: String,
whisker_gradle_plugin_version: String,
whisker_maven_url: String,
lynx_maven_url: String,
) -> Result<AndroidInputs> {
let ctx = engine
.compose(app_config, EnabledTargets::android_only())
.context("compose Whisker CNG plugin pipeline for Android")?;
let android_ir = ctx
.android
.as_ref()
.expect("EnabledTargets::android_only guarantees Some");
let app_name = android_ir
.app_name
.clone()
.ok_or_else(|| anyhow!("whisker.rs: app.name(\"…\") is required"))?;
let version = android_ir
.version
.clone()
.unwrap_or_else(|| "0.1.0".to_string());
let build_number = android_ir.build_number.unwrap_or(1);
let application_id = android_ir.application_id.clone().ok_or_else(|| {
anyhow!(
"whisker.rs: app.android(|a| a.application_id(\"…\")) (or app.bundle_id) is required for Android"
)
})?;
let min_sdk = android_ir.min_sdk.unwrap_or(24);
let target_sdk = android_ir.target_sdk.unwrap_or(34);
let extra_permissions = android_ir.manifest.permissions.clone();
let extra_meta_data = android_ir.manifest.application_meta_data.clone();
let extra_gradle_plugins = android_ir.gradle.apply_plugins.clone();
let extra_gradle_dependencies = android_ir.gradle.dependencies.clone();
let extra_files = android_ir.extra_files.clone();
Ok(AndroidInputs {
app_name,
version,
build_number,
application_id,
min_sdk,
target_sdk,
rust_lib_name,
whisker_workspace_path,
whisker_user_package,
whisker_sdk_version,
whisker_gradle_plugin_version,
whisker_maven_url,
lynx_maven_url,
extra_permissions,
extra_meta_data,
extra_gradle_plugins,
extra_gradle_dependencies,
extra_files,
template_version: 9,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn unique_tempdir() -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let p = std::env::temp_dir().join(format!("whisker-cng-android-test-{pid}-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
fn sample_inputs() -> AndroidInputs {
AndroidInputs {
app_name: "HelloWorld".into(),
version: "0.1.0".into(),
build_number: 1,
application_id: "rs.whisker.examples.helloworld".into(),
min_sdk: 24,
target_sdk: 34,
rust_lib_name: "hello_world".into(),
whisker_workspace_path: PathBuf::from("../.."),
whisker_user_package: "hello-world".into(),
whisker_sdk_version: "0.1.0".into(),
whisker_gradle_plugin_version: "0.1.0".into(),
whisker_maven_url: "https://whiskerrs.github.io/whisker/maven".into(),
lynx_maven_url: "https://whiskerrs.github.io/lynx/maven".into(),
extra_permissions: Vec::new(),
extra_meta_data: Vec::new(),
extra_gradle_plugins: Vec::new(),
extra_gradle_dependencies: Vec::new(),
extra_files: BTreeMap::new(),
template_version: 9,
}
}
#[test]
fn extra_files_writes_binary_contents_via_base64() {
let mut inputs = sample_inputs();
let raw = vec![0x00u8, 0x01, 0xfe, 0xff];
inputs.extra_files.insert(
PathBuf::from("app/src/main/assets/whisker/images/logo.png"),
FileEntry::binary(&raw),
);
let tmp = unique_tempdir();
let out = tmp.join("gen/android");
sync(&out, &inputs).unwrap();
let written =
std::fs::read(out.join("app/src/main/assets/whisker/images/logo.png")).unwrap();
assert_eq!(written, raw);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn template_vars_carry_required_keys() {
let inputs = sample_inputs();
let vars = template_vars(&inputs);
assert_eq!(
vars["android_application_id"],
"rs.whisker.examples.helloworld"
);
assert_eq!(vars["android_application_class"], "HelloWorldApplication");
assert_eq!(vars["android_min_sdk"], "24");
assert_eq!(vars["android_target_sdk"], "34");
assert_eq!(vars["rust_lib_name"], "hello_world");
assert_eq!(vars["build_number"], "1");
assert_eq!(vars["version"], "0.1.0");
}
#[test]
fn application_class_strips_punctuation() {
assert_eq!(
application_class_name("Hello World"),
"HelloWorldApplication"
);
assert_eq!(application_class_name("My-App"), "MyAppApplication");
}
#[test]
fn project_name_lowercases_and_appends_android_suffix() {
assert_eq!(project_name("HelloWorld"), "hello-world-android");
}
#[test]
fn application_id_to_path_splits_on_dots() {
assert_eq!(
application_id_to_path("rs.whisker.examples.helloworld"),
PathBuf::from("rs/whisker/examples/helloworld"),
);
}
#[test]
fn sync_writes_known_files_to_out_dir() {
let tmp = unique_tempdir();
let out = tmp.join("gen/android");
let regenerated = sync(&out, &sample_inputs()).expect("sync");
assert!(regenerated);
for expected in [
"app/build.gradle.kts",
"app/src/main/AndroidManifest.xml",
"app/src/main/kotlin/rs/whisker/examples/helloworld/MainActivity.kt",
"app/src/main/kotlin/rs/whisker/examples/helloworld/HelloWorldApplication.kt",
"build.gradle.kts",
"settings.gradle.kts",
"gradle.properties",
"gradlew",
"gradlew.bat",
"gradle/wrapper/gradle-wrapper.properties",
"gradle/wrapper/gradle-wrapper.jar",
".whisker-fingerprint",
] {
assert!(out.join(expected).exists(), "missing: {expected}");
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn sync_substitutes_placeholders_in_generated_files() {
let tmp = unique_tempdir();
let out = tmp.join("gen/android");
sync(&out, &sample_inputs()).unwrap();
let manifest =
std::fs::read_to_string(out.join("app/src/main/AndroidManifest.xml")).unwrap();
assert!(manifest.contains("android:name=\".HelloWorldApplication\""));
assert!(manifest.contains("android:label=\"HelloWorld\""));
assert!(!manifest.contains("{{"));
let main_activity = std::fs::read_to_string(
out.join("app/src/main/kotlin/rs/whisker/examples/helloworld/MainActivity.kt"),
)
.unwrap();
assert!(main_activity.starts_with("package rs.whisker.examples.helloworld\n"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn sync_is_idempotent_when_fingerprint_matches() {
let tmp = unique_tempdir();
let out = tmp.join("gen/android");
let first = sync(&out, &sample_inputs()).unwrap();
assert!(first);
let second = sync(&out, &sample_inputs()).unwrap();
assert!(!second, "second sync should be a no-op");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn sync_regenerates_when_inputs_change() {
let tmp = unique_tempdir();
let out = tmp.join("gen/android");
sync(&out, &sample_inputs()).unwrap();
let mut next = sample_inputs();
next.target_sdk = 35;
let regenerated = sync(&out, &next).unwrap();
assert!(regenerated);
let app_gradle = std::fs::read_to_string(out.join("app/build.gradle.kts")).unwrap();
assert!(app_gradle.contains("compileSdk = 35"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn sync_preserves_jnilibs_across_regeneration() {
let tmp = unique_tempdir();
let out = tmp.join("gen/android");
sync(&out, &sample_inputs()).unwrap();
let jni = out.join("app/src/main/jniLibs/arm64-v8a");
std::fs::create_dir_all(&jni).unwrap();
let dylib = jni.join("libhello_world.so");
std::fs::write(&dylib, b"FAKE_DYLIB").unwrap();
let mut next = sample_inputs();
next.min_sdk = 25;
sync(&out, &next).unwrap();
assert!(dylib.exists(), "dylib was wiped by re-sync");
assert_eq!(std::fs::read(&dylib).unwrap(), b"FAKE_DYLIB");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn inputs_from_errors_when_application_id_unset() {
let cfg = Config {
name: Some("X".into()),
..Config::default()
};
let err = inputs_from(
&cfg,
"x".into(),
PathBuf::new(),
"x".into(),
"0.1.0".into(),
"0.1.0".into(),
"https://whiskerrs.github.io/whisker/maven".into(),
"https://whiskerrs.github.io/lynx/maven".into(),
)
.unwrap_err();
assert!(err.to_string().contains("application_id"), "got: {err:#}");
}
}