Skip to main content

whisker_cng/
android.rs

1//! Render the Android host project under `gen/android/` from an
2//! [`Config`].
3//!
4//! The output mirrors a small AGP-flavoured Android Studio project:
5//!
6//! ```text
7//! gen/android/
8//! ├── app/
9//! │   ├── build.gradle.kts
10//! │   └── src/main/
11//! │       ├── AndroidManifest.xml
12//! │       ├── jniLibs/                          (populated at build time)
13//! │       └── kotlin/<package-path>/
14//! │           ├── MainActivity.kt
15//! │           └── <AppName>Application.kt
16//! ├── build.gradle.kts
17//! ├── settings.gradle.kts
18//! ├── gradle.properties
19//! ├── gradlew
20//! ├── gradlew.bat
21//! └── gradle/wrapper/
22//!     ├── gradle-wrapper.jar
23//!     └── gradle-wrapper.properties
24//! ```
25//!
26//! The package path under `kotlin/` is `applicationId` with dots
27//! converted to slashes: `rs.whisker.examples.helloworld` →
28//! `rs/whisker/examples/helloworld/`.
29
30use anyhow::{anyhow, Context, Result};
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Path, PathBuf};
33use whisker_config::Config;
34use whisker_plugin::{FileEntry, MetaDataEntry};
35
36use crate::compose::{EnabledTargets, Engine};
37use crate::fingerprint;
38use crate::render::{escape_xml, render};
39
40// ---- Embedded templates ----------------------------------------------------
41//
42// Text files go through `{{placeholder}}` substitution. Binary files
43// (the gradle wrapper jar) are copied verbatim. `gradlew` is text but
44// needs the +x bit on Unix so it lives in its own list.
45
46const APP_BUILD_GRADLE_KTS: &str = include_str!("templates/android/app/build.gradle.kts");
47const APP_MANIFEST_XML: &str = include_str!("templates/android/app/src/main/AndroidManifest.xml");
48const MAIN_ACTIVITY_KT: &str =
49    include_str!("templates/android/app/src/main/kotlin/MainActivity.kt");
50const APPLICATION_KT: &str = include_str!("templates/android/app/src/main/kotlin/Application.kt");
51const ROOT_BUILD_GRADLE_KTS: &str = include_str!("templates/android/build.gradle.kts");
52const SETTINGS_GRADLE_KTS: &str = include_str!("templates/android/settings.gradle.kts");
53const GRADLE_PROPERTIES: &str = include_str!("templates/android/gradle.properties");
54const GRADLEW: &str = include_str!("templates/android/gradlew");
55const GRADLEW_BAT: &str = include_str!("templates/android/gradlew.bat");
56const GRADLE_WRAPPER_PROPERTIES: &str =
57    include_str!("templates/android/gradle/wrapper/gradle-wrapper.properties");
58const GRADLE_WRAPPER_JAR: &[u8] =
59    include_bytes!("templates/android/gradle/wrapper/gradle-wrapper.jar");
60
61/// Inputs the Android renderer pulls out of `Config` (+ a few
62/// values the cli passes in like the dylib name and the workspace's
63/// `platforms/android/whisker-runtime` location).
64///
65/// Holding these in a struct rather than a big tuple keeps the
66/// fingerprint serialization stable and the template-vars build site
67/// easy to read.
68#[derive(Debug, Clone, serde::Serialize)]
69pub struct AndroidInputs {
70    pub app_name: String,
71    pub version: String,
72    pub build_number: u32,
73    pub application_id: String,
74    pub min_sdk: u32,
75    pub target_sdk: u32,
76    /// Crate name with hyphens replaced by underscores — what
77    /// `System.loadLibrary` and `keepDebugSymbols` reference.
78    pub rust_lib_name: String,
79    /// Path the generated `settings.gradle.kts` writes into the
80    /// `whisker { workspace = file(...) }` block. The Settings
81    /// plugin resolves it relative to `gen/android/`, so callers
82    /// typically pass `../..` (or similar) — the path to the cargo
83    /// workspace root containing the user app's `Cargo.toml`.
84    pub whisker_workspace_path: PathBuf,
85    /// Cargo crate name of the user app. Echoed into
86    /// `whisker { userPackage = "..." }`. The Settings plugin
87    /// walks the cargo dep graph rooted here for
88    /// `[package.metadata.whisker]`-tagged module deps.
89    pub whisker_user_package: String,
90    /// `rs.whisker:whisker-runtime-android:<this>` + sibling SDK
91    /// coords' version. Step 4.5-e initial release is `0.1.0`.
92    pub whisker_sdk_version: String,
93    /// `rs.whisker:rs.whisker.gradle.plugin:<this>` version pinned
94    /// in `pluginManagement.plugins`. Independent from
95    /// `whisker_sdk_version` — gradle-plugin and SDK release on
96    /// separate `gradle-plugin-v*` / `sdk-v*` tag streams.
97    pub whisker_gradle_plugin_version: String,
98    /// gh-pages Maven URL hosting Whisker's plugins + SDK. Templates
99    /// declare it in both `pluginManagement.repositories` and
100    /// `dependencyResolutionManagement.repositories`.
101    pub whisker_maven_url: String,
102    /// gh-pages Maven URL hosting the Lynx fork AARs that
103    /// `whisker-runtime-android` pulls transitively.
104    pub lynx_maven_url: String,
105    /// `<uses-permission android:name="…"/>` rows from the engine's
106    /// post-pipeline IR. Emitted after the template's hardcoded
107    /// `INTERNET` permission. Dedup'd: the same permission
108    /// contributed by multiple plugins shows up once.
109    #[serde(default)]
110    pub extra_permissions: Vec<String>,
111    /// `<meta-data android:name="…" android:value="…"/>` rows from
112    /// the engine's post-pipeline IR. Emitted inside the
113    /// `<application>` block. Preserves insertion order — multiple
114    /// plugins contributing entries see deterministic output.
115    #[serde(default)]
116    pub extra_meta_data: Vec<MetaDataEntry>,
117    /// Extra entries the renderer drops into the app module's
118    /// `plugins { … }` block, just after the baseline Whisker /
119    /// AGP / Kotlin plugin ids. Bare ids (e.g.
120    /// `"com.google.gms.google-services"`) get wrapped in
121    /// `id("…")`; raw `id(...)` lines pass through verbatim so
122    /// users can attach `version "…"` / `apply false` qualifiers.
123    #[serde(default)]
124    pub extra_gradle_plugins: Vec<String>,
125    /// Extra raw lines the renderer drops into the app module's
126    /// `dependencies { … }` block. Each entry is emitted verbatim
127    /// (e.g.
128    /// `"implementation(\"com.google.firebase:firebase-analytics:21.5.0\")"`).
129    #[serde(default)]
130    pub extra_gradle_dependencies: Vec<String>,
131    /// Plugin-supplied additional files dropped into `gen/android/`.
132    /// Keys are relative paths (validated at write time); values
133    /// are [`FileEntry`]s — UTF-8 contents + optional POSIX mode.
134    ///
135    /// Mode handling on Android is intentionally coarser than the
136    /// iOS renderer's: the existing `write_file` helper takes a
137    /// `bool` executable flag, so the renderer projects
138    /// `FileEntry::mode` onto "executable yes/no" (any mode with
139    /// the user-execute bit set → 0o755, otherwise 0o644). Plugins
140    /// that need finer-grained Android permissions today would have
141    /// to ship a wrapper script that `chmod`s at build time —
142    /// loosening this is a one-line `write_file` refactor when the
143    /// first consumer needs it.
144    #[serde(default)]
145    pub extra_files: BTreeMap<PathBuf, FileEntry>,
146    /// Bumped whenever the template *shape* changes (added file,
147    /// renamed placeholder, …). The fingerprint mixes this in so
148    /// existing `gen/` trees regenerate after an upgrade.
149    pub template_version: u32,
150}
151
152/// Render the Android project into `out_dir` (typically
153/// `<crate_dir>/gen/android`). Returns whether files were actually
154/// rewritten — `false` means the cached fingerprint matched and the
155/// existing tree was reused. The caller decides what to do with that
156/// (log "in sync", skip a downstream sync, …).
157pub fn sync(out_dir: &Path, inputs: &AndroidInputs) -> Result<bool> {
158    let new_fp = fingerprint::fingerprint(
159        serde_json::to_vec(inputs)
160            .context("serialize AndroidInputs for fingerprint")?
161            .as_slice(),
162    );
163    let fp_path = out_dir.join(".whisker-fingerprint");
164    if let Ok(existing) = std::fs::read_to_string(&fp_path) {
165        if existing.trim() == new_fp {
166            return Ok(false);
167        }
168    }
169
170    write_files(out_dir, inputs).context("write Android project files")?;
171    std::fs::write(&fp_path, &new_fp)
172        .with_context(|| format!("write fingerprint {}", fp_path.display()))?;
173    Ok(true)
174}
175
176/// Build the `{{var}}` table from `inputs`. Split out so unit tests
177/// can assert against the result without going through file I/O.
178pub(crate) fn template_vars(inputs: &AndroidInputs) -> HashMap<&'static str, String> {
179    let mut v = HashMap::new();
180    v.insert("app_name", inputs.app_name.clone());
181    v.insert("version", inputs.version.clone());
182    v.insert("build_number", inputs.build_number.to_string());
183    v.insert("android_application_id", inputs.application_id.clone());
184    v.insert(
185        "android_application_class",
186        application_class_name(&inputs.app_name),
187    );
188    v.insert("android_min_sdk", inputs.min_sdk.to_string());
189    v.insert("android_target_sdk", inputs.target_sdk.to_string());
190    v.insert("android_project_name", project_name(&inputs.app_name));
191    v.insert("rust_lib_name", inputs.rust_lib_name.clone());
192    v.insert(
193        "whisker_workspace_path",
194        inputs.whisker_workspace_path.display().to_string(),
195    );
196    v.insert("whisker_user_package", inputs.whisker_user_package.clone());
197    v.insert("whisker_sdk_version", inputs.whisker_sdk_version.clone());
198    v.insert(
199        "whisker_gradle_plugin_version",
200        inputs.whisker_gradle_plugin_version.clone(),
201    );
202    v.insert("whisker_maven_url", inputs.whisker_maven_url.clone());
203    v.insert("lynx_maven_url", inputs.lynx_maven_url.clone());
204    v.insert(
205        "extra_uses_permissions",
206        render_extra_permissions(&inputs.extra_permissions),
207    );
208    v.insert(
209        "extra_application_meta_data",
210        render_extra_meta_data(&inputs.extra_meta_data),
211    );
212    v.insert(
213        "extra_gradle_plugins",
214        render_extra_gradle_plugins(&inputs.extra_gradle_plugins),
215    );
216    v.insert(
217        "extra_gradle_dependencies",
218        render_extra_gradle_dependencies(&inputs.extra_gradle_dependencies),
219    );
220    v
221}
222
223/// Render `apply_plugins` entries as Kotlin DSL lines inside the
224/// `plugins { … }` block. Two shapes:
225///
226///   - Bare gradle plugin id (e.g. `"com.google.gms.google-services"`)
227///     → wrapped in `id("…")`.
228///   - Anything containing a `(` character (e.g. `id("…") version "X"`,
229///     `alias(libs.plugins.foo)`, `kotlin("jvm")`) → emitted
230///     verbatim. The Kotlin DSL's plugin block accepts every
231///     callable that returns a `PluginDependencySpec`, and bare
232///     gradle plugin ids never contain `(`, so this is a safe
233///     discriminator.
234fn render_extra_gradle_plugins(entries: &[String]) -> String {
235    if entries.is_empty() {
236        return String::new();
237    }
238    let mut out = String::new();
239    for entry in entries {
240        if entry.contains('(') {
241            out.push_str(&format!("    {entry}\n"));
242        } else {
243            out.push_str(&format!("    id(\"{entry}\")\n"));
244        }
245    }
246    if out.ends_with('\n') {
247        out.pop();
248    }
249    out
250}
251
252fn render_extra_gradle_dependencies(entries: &[String]) -> String {
253    if entries.is_empty() {
254        return String::new();
255    }
256    let mut out = String::new();
257    for entry in entries {
258        out.push_str(&format!("    {entry}\n"));
259    }
260    if out.ends_with('\n') {
261        out.pop();
262    }
263    out
264}
265
266/// Render the engine-supplied permissions as `<uses-permission>`
267/// rows, dedup'd. Empty input → empty string so the template still
268/// parses when no plugin contributed.
269fn render_extra_permissions(perms: &[String]) -> String {
270    if perms.is_empty() {
271        return String::new();
272    }
273    let mut seen = std::collections::BTreeSet::new();
274    let mut out = String::new();
275    for p in perms {
276        if seen.insert(p.as_str()) {
277            out.push_str(&format!(
278                "    <uses-permission android:name=\"{}\" />\n",
279                escape_xml(p),
280            ));
281        }
282    }
283    if out.ends_with('\n') {
284        out.pop();
285    }
286    out
287}
288
289fn render_extra_meta_data(entries: &[MetaDataEntry]) -> String {
290    if entries.is_empty() {
291        return String::new();
292    }
293    let mut out = String::new();
294    for e in entries {
295        out.push_str(&format!(
296            "        <meta-data android:name=\"{}\" android:value=\"{}\" />\n",
297            escape_xml(&e.name),
298            escape_xml(&e.value),
299        ));
300    }
301    if out.ends_with('\n') {
302        out.pop();
303    }
304    out
305}
306
307/// Application class. `HelloWorld` → `HelloWorldApplication`. Strips
308/// non-identifier characters and ensures the leading char is alpha.
309fn application_class_name(app_name: &str) -> String {
310    let cleaned: String = app_name
311        .chars()
312        .filter(|c| c.is_ascii_alphanumeric())
313        .collect();
314    if cleaned.is_empty() {
315        return "WhiskerApp_Application".into();
316    }
317    format!("{cleaned}Application")
318}
319
320/// `rootProject.name`. Lowercase, hyphenated form of the app name —
321/// e.g. `Podcast` → `podcast-android`. Matches the existing
322/// example convention (gradle warns on uppercase project names).
323fn project_name(app_name: &str) -> String {
324    let mut out = String::new();
325    for (i, c) in app_name.chars().enumerate() {
326        if c.is_ascii_uppercase() && i > 0 {
327            out.push('-');
328        }
329        out.extend(c.to_lowercase());
330    }
331    if out.is_empty() {
332        out.push_str("whisker-app");
333    }
334    format!("{out}-android")
335}
336
337/// Convert `rs.whisker.examples.helloworld` → `rs/whisker/examples/helloworld`.
338/// Used to build the on-disk path under `app/src/main/kotlin/`.
339fn application_id_to_path(application_id: &str) -> PathBuf {
340    application_id
341        .split('.')
342        .filter(|s| !s.is_empty())
343        .fold(PathBuf::new(), |acc, seg| acc.join(seg))
344}
345
346fn write_files(out_dir: &Path, inputs: &AndroidInputs) -> Result<()> {
347    let vars = template_vars(inputs);
348
349    // Wipe the existing tree, but spare anything we know is a runtime
350    // build artifact (so we don't blow away gradle's cache on every
351    // sync). Today that means `app/build/`, `.gradle/`, and the
352    // `app/src/main/jniLibs/` directory whose bytes are produced by
353    // `cargo build` outside the renderer.
354    clean_managed_tree(out_dir).context("clean previous gen tree")?;
355
356    let kotlin_pkg = out_dir
357        .join("app/src/main/kotlin")
358        .join(application_id_to_path(&inputs.application_id));
359
360    let app_class_filename = format!("{}.kt", application_class_name(&inputs.app_name));
361
362    // Text templates.
363    let text_files: &[(PathBuf, &str)] = &[
364        (out_dir.join("app/build.gradle.kts"), APP_BUILD_GRADLE_KTS),
365        (
366            out_dir.join("app/src/main/AndroidManifest.xml"),
367            APP_MANIFEST_XML,
368        ),
369        (kotlin_pkg.join("MainActivity.kt"), MAIN_ACTIVITY_KT),
370        (kotlin_pkg.join(&app_class_filename), APPLICATION_KT),
371        (out_dir.join("build.gradle.kts"), ROOT_BUILD_GRADLE_KTS),
372        (out_dir.join("settings.gradle.kts"), SETTINGS_GRADLE_KTS),
373        (out_dir.join("gradle.properties"), GRADLE_PROPERTIES),
374        (
375            out_dir.join("gradle/wrapper/gradle-wrapper.properties"),
376            GRADLE_WRAPPER_PROPERTIES,
377        ),
378    ];
379    for (path, template) in text_files {
380        let rendered =
381            render(template, &vars).with_context(|| format!("render {}", path.display()))?;
382        write_file(path, rendered.as_bytes(), false)?;
383    }
384
385    // `gradlew` is shell — needs +x.
386    write_file(&out_dir.join("gradlew"), GRADLEW.as_bytes(), true)?;
387    write_file(&out_dir.join("gradlew.bat"), GRADLEW_BAT.as_bytes(), false)?;
388
389    // Binary.
390    write_file(
391        &out_dir.join("gradle/wrapper/gradle-wrapper.jar"),
392        GRADLE_WRAPPER_JAR,
393        false,
394    )?;
395
396    // Plugin-supplied `extra_files`. Paths are validated to be
397    // relative and traversal-free; on Unix, `mode` is applied via
398    // the existing `write_file` executable flag (0o755 when set
399    // and `>= 0o100`, otherwise the default 0o644).
400    for (rel, entry) in &inputs.extra_files {
401        crate::render::validate_extra_file_path(rel).with_context(|| {
402            format!(
403                "extra_files entry `{}` (Android plugin contribution)",
404                rel.display(),
405            )
406        })?;
407        let abs = out_dir.join(rel);
408        // The Android renderer's `write_file` takes a `bool`
409        // executable flag (0o755 on Unix). Apply that for any
410        // mode that has the user-execute bit set.
411        let executable = entry.mode.map(|m| m & 0o100 != 0).unwrap_or(false);
412        let bytes = entry
413            .to_bytes()
414            .with_context(|| format!("decode extra_files entry `{}` contents", rel.display()))?;
415        write_file(&abs, &bytes, executable)?;
416    }
417
418    Ok(())
419}
420
421/// Delete the previous gen tree but keep `app/build/`, `.gradle/`,
422/// and `app/src/main/jniLibs/`. These three are runtime build
423/// artifacts; wiping them on every sync forces gradle into a cold
424/// rebuild and `cargo build` to re-copy the dylib, which would make
425/// the dev loop unbearable.
426fn clean_managed_tree(out_dir: &Path) -> Result<()> {
427    if !out_dir.exists() {
428        return Ok(());
429    }
430    let keep = ["app/build", ".gradle", "app/src/main/jniLibs"];
431    for entry in
432        std::fs::read_dir(out_dir).with_context(|| format!("read_dir {}", out_dir.display()))?
433    {
434        let entry = entry?;
435        let rel = entry
436            .path()
437            .strip_prefix(out_dir)
438            .map(|p| p.to_path_buf())
439            .ok();
440        if let Some(rel) = rel {
441            if keep.iter().any(|k| rel == Path::new(k)) {
442                continue;
443            }
444        }
445        // Don't blow away top-level `app/` either — only the files we
446        // own under it. Recurse one level.
447        if entry.file_name() == "app" && entry.path().is_dir() {
448            clean_under_app(&entry.path())?;
449            continue;
450        }
451        // Skip our own fingerprint file — it'll be overwritten in `sync`.
452        if entry.file_name() == ".whisker-fingerprint" {
453            continue;
454        }
455        remove_path(&entry.path())?;
456    }
457    Ok(())
458}
459
460fn clean_under_app(app_dir: &Path) -> Result<()> {
461    for entry in
462        std::fs::read_dir(app_dir).with_context(|| format!("read_dir {}", app_dir.display()))?
463    {
464        let entry = entry?;
465        // Keep `build/` (gradle's output) and the jniLibs subtree.
466        if entry.file_name() == "build" {
467            continue;
468        }
469        if entry.path().is_dir() && entry.file_name() == "src" {
470            clean_under_src(&entry.path())?;
471            continue;
472        }
473        remove_path(&entry.path())?;
474    }
475    Ok(())
476}
477
478fn clean_under_src(src_dir: &Path) -> Result<()> {
479    for entry in
480        std::fs::read_dir(src_dir).with_context(|| format!("read_dir {}", src_dir.display()))?
481    {
482        let entry = entry?;
483        if entry.path().is_dir() && entry.file_name() == "main" {
484            clean_under_main(&entry.path())?;
485            continue;
486        }
487        remove_path(&entry.path())?;
488    }
489    Ok(())
490}
491
492fn clean_under_main(main_dir: &Path) -> Result<()> {
493    for entry in
494        std::fs::read_dir(main_dir).with_context(|| format!("read_dir {}", main_dir.display()))?
495    {
496        let entry = entry?;
497        // Keep the jniLibs subtree (dylib drops here).
498        if entry.file_name() == "jniLibs" {
499            continue;
500        }
501        remove_path(&entry.path())?;
502    }
503    Ok(())
504}
505
506fn remove_path(p: &Path) -> Result<()> {
507    if p.is_dir() {
508        std::fs::remove_dir_all(p).with_context(|| format!("rm -rf {}", p.display()))
509    } else {
510        std::fs::remove_file(p).with_context(|| format!("rm {}", p.display()))
511    }
512}
513
514fn write_file(path: &Path, bytes: &[u8], executable: bool) -> Result<()> {
515    if let Some(parent) = path.parent() {
516        std::fs::create_dir_all(parent)
517            .with_context(|| format!("mkdir -p {}", parent.display()))?;
518    }
519    std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
520    #[cfg(unix)]
521    if executable {
522        use std::os::unix::fs::PermissionsExt;
523        let mut perms = std::fs::metadata(path)?.permissions();
524        perms.set_mode(0o755);
525        std::fs::set_permissions(path, perms)?;
526    }
527    #[cfg(not(unix))]
528    let _ = executable;
529    Ok(())
530}
531
532/// Pull the Android-relevant subset of `Config` into the renderer
533/// input struct. Errors out on required-but-missing fields (an
534/// applicationId is mandatory; everything else has a default).
535///
536/// Thin wrapper over [`inputs_from_with_engine`] using
537/// [`Engine::with_builtins`]. Callers that want extra plugins
538/// (e.g. subprocess plugins discovered via cargo metadata) should
539/// call the `_with_engine` form directly.
540// Eight arguments — over clippy's seven-arg default. Bundling them
541// behind a builder or a config struct would just push the same value
542// list one level deeper without changing the call site, so allow.
543#[allow(clippy::too_many_arguments)]
544pub fn inputs_from(
545    app_config: &Config,
546    rust_lib_name: String,
547    whisker_workspace_path: PathBuf,
548    whisker_user_package: String,
549    whisker_sdk_version: String,
550    whisker_gradle_plugin_version: String,
551    whisker_maven_url: String,
552    lynx_maven_url: String,
553) -> Result<AndroidInputs> {
554    inputs_from_with_engine(
555        &Engine::with_builtins(),
556        app_config,
557        rust_lib_name,
558        whisker_workspace_path,
559        whisker_user_package,
560        whisker_sdk_version,
561        whisker_gradle_plugin_version,
562        whisker_maven_url,
563        lynx_maven_url,
564    )
565}
566
567/// Like [`inputs_from`] but takes a pre-built [`Engine`] so the
568/// caller can register additional plugins (e.g. subprocess plugins
569/// discovered from `[package.metadata.whisker.plugins]`).
570#[allow(clippy::too_many_arguments)]
571pub fn inputs_from_with_engine(
572    engine: &Engine,
573    app_config: &Config,
574    rust_lib_name: String,
575    whisker_workspace_path: PathBuf,
576    whisker_user_package: String,
577    whisker_sdk_version: String,
578    whisker_gradle_plugin_version: String,
579    whisker_maven_url: String,
580    lynx_maven_url: String,
581) -> Result<AndroidInputs> {
582    // Run the plugin pipeline. `build_initial_context` seeds the
583    // IR with core fields from `Config`; plugins can override
584    // any of them. The renderer reads the post-pipeline IR.
585    let ctx = engine
586        .compose(app_config, EnabledTargets::android_only())
587        .context("compose Whisker CNG plugin pipeline for Android")?;
588    let android_ir = ctx
589        .android
590        .as_ref()
591        .expect("EnabledTargets::android_only guarantees Some");
592
593    let app_name = android_ir
594        .app_name
595        .clone()
596        .ok_or_else(|| anyhow!("whisker.rs: app.name(\"…\") is required"))?;
597    let version = android_ir
598        .version
599        .clone()
600        .unwrap_or_else(|| "0.1.0".to_string());
601    let build_number = android_ir.build_number.unwrap_or(1);
602    let application_id = android_ir.application_id.clone().ok_or_else(|| {
603        anyhow!(
604            "whisker.rs: app.android(|a| a.application_id(\"…\")) (or app.bundle_id) is required for Android"
605        )
606    })?;
607    let min_sdk = android_ir.min_sdk.unwrap_or(24);
608    let target_sdk = android_ir.target_sdk.unwrap_or(34);
609
610    let extra_permissions = android_ir.manifest.permissions.clone();
611    let extra_meta_data = android_ir.manifest.application_meta_data.clone();
612    let extra_gradle_plugins = android_ir.gradle.apply_plugins.clone();
613    let extra_gradle_dependencies = android_ir.gradle.dependencies.clone();
614    let extra_files = android_ir.extra_files.clone();
615
616    Ok(AndroidInputs {
617        app_name,
618        version,
619        build_number,
620        application_id,
621        min_sdk,
622        target_sdk,
623        rust_lib_name,
624        whisker_workspace_path,
625        whisker_user_package,
626        whisker_sdk_version,
627        whisker_gradle_plugin_version,
628        whisker_maven_url,
629        lynx_maven_url,
630        extra_permissions,
631        extra_meta_data,
632        extra_gradle_plugins,
633        extra_gradle_dependencies,
634        extra_files,
635        // Bumped 8 → 9 for `extra_files` write-through (RFC #164
636        // B-direction PR 3). The fingerprint shape grew an
637        // `extra_files` field; existing trees regenerate.
638        template_version: 9,
639    })
640}
641
642// ============================================================================
643// Tests
644// ============================================================================
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use std::sync::atomic::{AtomicU64, Ordering};
650
651    fn unique_tempdir() -> PathBuf {
652        static SEQ: AtomicU64 = AtomicU64::new(0);
653        let n = SEQ.fetch_add(1, Ordering::Relaxed);
654        let pid = std::process::id();
655        let p = std::env::temp_dir().join(format!("whisker-cng-android-test-{pid}-{n}"));
656        std::fs::create_dir_all(&p).unwrap();
657        p
658    }
659
660    fn sample_inputs() -> AndroidInputs {
661        AndroidInputs {
662            app_name: "HelloWorld".into(),
663            version: "0.1.0".into(),
664            build_number: 1,
665            application_id: "rs.whisker.examples.helloworld".into(),
666            min_sdk: 24,
667            target_sdk: 34,
668            rust_lib_name: "hello_world".into(),
669            whisker_workspace_path: PathBuf::from("../.."),
670            whisker_user_package: "hello-world".into(),
671            whisker_sdk_version: "0.1.0".into(),
672            whisker_gradle_plugin_version: "0.1.0".into(),
673            whisker_maven_url: "https://whiskerrs.github.io/whisker/maven".into(),
674            lynx_maven_url: "https://whiskerrs.github.io/lynx/maven".into(),
675            extra_permissions: Vec::new(),
676            extra_meta_data: Vec::new(),
677            extra_gradle_plugins: Vec::new(),
678            extra_gradle_dependencies: Vec::new(),
679            extra_files: BTreeMap::new(),
680            template_version: 9,
681        }
682    }
683
684    #[test]
685    fn extra_files_writes_binary_contents_via_base64() {
686        // whisker-asset drops assets under app/src/main/assets/whisker/
687        // as base64 FileEntry::binary — the renderer must decode them.
688        let mut inputs = sample_inputs();
689        let raw = vec![0x00u8, 0x01, 0xfe, 0xff];
690        inputs.extra_files.insert(
691            PathBuf::from("app/src/main/assets/whisker/images/logo.png"),
692            FileEntry::binary(&raw),
693        );
694        let tmp = unique_tempdir();
695        let out = tmp.join("gen/android");
696        sync(&out, &inputs).unwrap();
697        let written =
698            std::fs::read(out.join("app/src/main/assets/whisker/images/logo.png")).unwrap();
699        assert_eq!(written, raw);
700        let _ = std::fs::remove_dir_all(&tmp);
701    }
702
703    #[test]
704    fn template_vars_carry_required_keys() {
705        let inputs = sample_inputs();
706        let vars = template_vars(&inputs);
707        assert_eq!(
708            vars["android_application_id"],
709            "rs.whisker.examples.helloworld"
710        );
711        assert_eq!(vars["android_application_class"], "HelloWorldApplication");
712        assert_eq!(vars["android_min_sdk"], "24");
713        assert_eq!(vars["android_target_sdk"], "34");
714        assert_eq!(vars["rust_lib_name"], "hello_world");
715        assert_eq!(vars["build_number"], "1");
716        assert_eq!(vars["version"], "0.1.0");
717    }
718
719    #[test]
720    fn application_class_strips_punctuation() {
721        assert_eq!(
722            application_class_name("Hello World"),
723            "HelloWorldApplication"
724        );
725        assert_eq!(application_class_name("My-App"), "MyAppApplication");
726    }
727
728    #[test]
729    fn project_name_lowercases_and_appends_android_suffix() {
730        assert_eq!(project_name("HelloWorld"), "hello-world-android");
731    }
732
733    #[test]
734    fn application_id_to_path_splits_on_dots() {
735        assert_eq!(
736            application_id_to_path("rs.whisker.examples.helloworld"),
737            PathBuf::from("rs/whisker/examples/helloworld"),
738        );
739    }
740
741    #[test]
742    fn sync_writes_known_files_to_out_dir() {
743        let tmp = unique_tempdir();
744        let out = tmp.join("gen/android");
745        let regenerated = sync(&out, &sample_inputs()).expect("sync");
746        assert!(regenerated);
747
748        for expected in [
749            "app/build.gradle.kts",
750            "app/src/main/AndroidManifest.xml",
751            "app/src/main/kotlin/rs/whisker/examples/helloworld/MainActivity.kt",
752            "app/src/main/kotlin/rs/whisker/examples/helloworld/HelloWorldApplication.kt",
753            "build.gradle.kts",
754            "settings.gradle.kts",
755            "gradle.properties",
756            "gradlew",
757            "gradlew.bat",
758            "gradle/wrapper/gradle-wrapper.properties",
759            "gradle/wrapper/gradle-wrapper.jar",
760            ".whisker-fingerprint",
761        ] {
762            assert!(out.join(expected).exists(), "missing: {expected}");
763        }
764
765        let _ = std::fs::remove_dir_all(&tmp);
766    }
767
768    #[test]
769    fn sync_substitutes_placeholders_in_generated_files() {
770        let tmp = unique_tempdir();
771        let out = tmp.join("gen/android");
772        sync(&out, &sample_inputs()).unwrap();
773
774        let manifest =
775            std::fs::read_to_string(out.join("app/src/main/AndroidManifest.xml")).unwrap();
776        assert!(manifest.contains("android:name=\".HelloWorldApplication\""));
777        assert!(manifest.contains("android:label=\"HelloWorld\""));
778        assert!(!manifest.contains("{{"));
779
780        let main_activity = std::fs::read_to_string(
781            out.join("app/src/main/kotlin/rs/whisker/examples/helloworld/MainActivity.kt"),
782        )
783        .unwrap();
784        assert!(main_activity.starts_with("package rs.whisker.examples.helloworld\n"));
785
786        let _ = std::fs::remove_dir_all(&tmp);
787    }
788
789    #[test]
790    fn sync_is_idempotent_when_fingerprint_matches() {
791        let tmp = unique_tempdir();
792        let out = tmp.join("gen/android");
793        let first = sync(&out, &sample_inputs()).unwrap();
794        assert!(first);
795        let second = sync(&out, &sample_inputs()).unwrap();
796        assert!(!second, "second sync should be a no-op");
797
798        let _ = std::fs::remove_dir_all(&tmp);
799    }
800
801    #[test]
802    fn sync_regenerates_when_inputs_change() {
803        let tmp = unique_tempdir();
804        let out = tmp.join("gen/android");
805        sync(&out, &sample_inputs()).unwrap();
806        let mut next = sample_inputs();
807        next.target_sdk = 35;
808        let regenerated = sync(&out, &next).unwrap();
809        assert!(regenerated);
810        let app_gradle = std::fs::read_to_string(out.join("app/build.gradle.kts")).unwrap();
811        assert!(app_gradle.contains("compileSdk = 35"));
812
813        let _ = std::fs::remove_dir_all(&tmp);
814    }
815
816    #[test]
817    fn sync_preserves_jnilibs_across_regeneration() {
818        // The dylib `cargo build` drops here must survive a sync.
819        let tmp = unique_tempdir();
820        let out = tmp.join("gen/android");
821        sync(&out, &sample_inputs()).unwrap();
822        let jni = out.join("app/src/main/jniLibs/arm64-v8a");
823        std::fs::create_dir_all(&jni).unwrap();
824        let dylib = jni.join("libhello_world.so");
825        std::fs::write(&dylib, b"FAKE_DYLIB").unwrap();
826
827        let mut next = sample_inputs();
828        next.min_sdk = 25;
829        sync(&out, &next).unwrap();
830        assert!(dylib.exists(), "dylib was wiped by re-sync");
831        assert_eq!(std::fs::read(&dylib).unwrap(), b"FAKE_DYLIB");
832
833        let _ = std::fs::remove_dir_all(&tmp);
834    }
835
836    #[test]
837    fn inputs_from_errors_when_application_id_unset() {
838        let cfg = Config {
839            name: Some("X".into()),
840            ..Config::default()
841        };
842        let err = inputs_from(
843            &cfg,
844            "x".into(),
845            PathBuf::new(),
846            "x".into(),
847            "0.1.0".into(),
848            "0.1.0".into(),
849            "https://whiskerrs.github.io/whisker/maven".into(),
850            "https://whiskerrs.github.io/lynx/maven".into(),
851        )
852        .unwrap_err();
853        assert!(err.to_string().contains("application_id"), "got: {err:#}");
854    }
855}