diff --git a/changelog.d/11276-bundled-android-template.md b/changelog.d/11276-bundled-android-template.md new file mode 100644 index 0000000000..1b9caf4edd --- /dev/null +++ b/changelog.d/11276-bundled-android-template.md @@ -0,0 +1,7 @@ +Bundle the Android Gradle template, Kotlin/Java bridge, and resources in the +compiler so `perry run android --local` and Wear OS packaging work from release +installs without a Perry source checkout. Build-time collection tracks new +resources while excluding Gradle caches, local SDK paths, generated output, and +JNI binaries. Includes extraction, resource-fidelity, collector, and Wear OS +regression coverage; the extracted template was built into a debug APK with +Gradle 9.4, JDK 21, and Android SDK 35. diff --git a/crates/perry/build.rs b/crates/perry/build.rs index 9b7da00697..2ee16a864e 100644 --- a/crates/perry/build.rs +++ b/crates/perry/build.rs @@ -1,4 +1,8 @@ +#[path = "build/android_template.rs"] +mod android_template; + fn main() { + android_template::emit().expect("failed to embed Android Gradle template"); println!("cargo:rerun-if-changed=Cargo.toml"); if std::env::var_os("CARGO_CFG_TARGET_OS").as_deref() != Some(std::ffi::OsStr::new("windows")) { diff --git a/crates/perry/build/android_template.rs b/crates/perry/build/android_template.rs new file mode 100644 index 0000000000..4f82840216 --- /dev/null +++ b/crates/perry/build/android_template.rs @@ -0,0 +1,105 @@ +//! Embed the checked-in Gradle project without development-machine build output. +use std::{env, fs, io, path::Path}; + +pub fn emit() -> io::Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../perry-ui-android/template"); + let mut files = Vec::new(); + collect(&root, &root, &mut files)?; + files.sort(); + let mut source = String::from("pub(super) static FILES: &[(&str, &[u8])] = &[\n"); + for name in files { + let path = root.join(&name); + println!("cargo:rerun-if-changed={}", path.display()); + source.push_str(&format!( + " ({:?}, include_bytes!({:?})),\n", + name.replace('\\', "/"), + path.to_str().expect("Android template path must be UTF-8") + )); + } + source.push_str("];"); + fs::write( + Path::new(&env::var_os("OUT_DIR").unwrap()).join("android_template.rs"), + source, + ) +} + +fn collect(root: &Path, dir: &Path, files: &mut Vec) -> io::Result<()> { + // Watching directories also detects newly added template resources. + println!("cargo:rerun-if-changed={}", dir.display()); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let relative = entry + .path() + .strip_prefix(root) + .unwrap() + .to_str() + .unwrap() + .replace('\\', "/"); + // Gradle caches, generated wrappers/builds, machine-specific SDK paths, + // and native outputs are not template sources. They must not be shipped. + if name.starts_with('.') + || matches!( + name.as_ref(), + "build" | "gradlew" | "gradlew.bat" | "local.properties" | "jniLibs" + ) + || relative == "gradle/wrapper" + { + continue; + } + let kind = entry.file_type()?; + if kind.is_symlink() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Android template must not contain symlinks: {}", + entry.path().display() + ), + )); + } + if kind.is_dir() { + collect(root, &entry.path(), files)?; + } else if kind.is_file() { + files.push(relative); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn android_template_collector_includes_new_resources_but_not_machine_files() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for name in [ + "gradle/libs.versions.toml", + "app/src/main/res/drawable/new_icon.png", + "app/src/main/java/com/perry/app/NewBridge.kt", + "local.properties", + ".gradle/cache", + "app/build/generated/secret", + "app/src/main/jniLibs/arm64-v8a/libperry_app.so", + "gradlew", + "gradle/wrapper/gradle-wrapper.jar", + ] { + let file = root.join(name); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, [0, 255, 128, 10]).unwrap(); + } + let mut files = Vec::new(); + collect(root, root, &mut files).unwrap(); + files.sort(); + assert_eq!( + files, + [ + "app/src/main/java/com/perry/app/NewBridge.kt", + "app/src/main/res/drawable/new_icon.png", + "gradle/libs.versions.toml", + ] + ); + } +} diff --git a/crates/perry/src/commands/run/android.rs b/crates/perry/src/commands/run/android.rs index ef60b58cdf..564f7bb796 100644 --- a/crates/perry/src/commands/run/android.rs +++ b/crates/perry/src/commands/run/android.rs @@ -5,7 +5,7 @@ use super::*; /// Build an Android APK from the compiled .so and install/launch on a device. /// /// Steps: -/// 1. Copy the Gradle template from perry-ui-android/template/ to a temp dir +/// 1. Extract the bundled Gradle template into the build directory /// 2. Place the compiled .so in app/src/main/jniLibs/arm64-v8a/ /// 3. Update the applicationId in build.gradle.kts /// 4. Run ./gradlew assembleDebug @@ -41,14 +41,6 @@ fn build_and_run_android_impl( format: OutputFormat, wear: bool, ) -> Result<()> { - // Find the perry workspace root to locate the Android template - let workspace_root = super::super::compile::find_perry_workspace_root() - .ok_or_else(|| anyhow!("Cannot find Perry workspace root — needed for Android template"))?; - let template_dir = workspace_root.join("crates/perry-ui-android/template"); - if !template_dir.exists() { - bail!("Android template not found at {}", template_dir.display()); - } - // Create a build directory alongside the .so let build_dir = so_path .parent() @@ -63,9 +55,10 @@ fn build_and_run_android_impl( println!("Building Android APK..."); } - // Copy template to build directory - copy_dir_recursive(&template_dir, &build_dir) - .map_err(|e| anyhow!("Failed to copy Android template: {}", e))?; + // The release binary carries the matching Kotlin bridge and resources. + // This path must work without a Perry checkout on the user's machine. + super::android_template::extract(&build_dir) + .map_err(|e| anyhow!("Failed to extract Android template: {}", e))?; // Wear OS: overlay the watch form-factor onto the copied phone template // (manifest feature + standalone meta-data, Wear minSdk, androidx.wear dep). @@ -1084,6 +1077,23 @@ pub fn get_android_pid(serial: &str, bundle_id: &str) -> String { mod tests { use super::*; + #[test] + fn wear_overlay_applies_to_bundled_android_template() { + let temp = tempfile::tempdir().unwrap(); + super::super::android_template::extract(temp.path()).unwrap(); + apply_wear_overlay(temp.path(), OutputFormat::Json).unwrap(); + let manifest = + std::fs::read_to_string(temp.path().join("app/src/main/AndroidManifest.xml")).unwrap(); + let gradle = std::fs::read_to_string(temp.path().join("app/build.gradle.kts")).unwrap(); + assert!(manifest.contains("android.hardware.type.watch")); + assert!(manifest.contains("com.google.android.wearable.standalone")); + assert!(gradle.contains("minSdk = 30")); + assert!(temp + .path() + .join("app/src/main/java/com/perry/app/PerryBridge.kt") + .is_file()); + } + /// `apply_wear_overlay` must transform a copy of the *real* Android template /// into a Wear OS project: watch feature + standalone meta-data in the /// manifest, and `androidx.wear` + `minSdk = 30` in the Gradle build. This diff --git a/crates/perry/src/commands/run/android_template.rs b/crates/perry/src/commands/run/android_template.rs new file mode 100644 index 0000000000..975bb6eaf8 --- /dev/null +++ b/crates/perry/src/commands/run/android_template.rs @@ -0,0 +1,75 @@ +//! The Android/Wear OS project ships inside the compiler, including resources +//! and Kotlin/Java bridge sources. build.rs derives this table from the template. +use std::{fs, io, path::Path}; + +include!(concat!(env!("OUT_DIR"), "/android_template.rs")); + +pub(super) fn extract(destination: &Path) -> io::Result<()> { + for (relative, contents) in FILES { + let path = destination.join(relative); + fs::create_dir_all(path.parent().unwrap())?; + fs::write(path, contents)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_android_template_extracts_without_a_checkout() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("release-app/android-build"); + extract(&destination).unwrap(); + // Required packaging/bridge inputs, not just a non-empty output tree. + for relative in [ + "build.gradle.kts", + "settings.gradle.kts", + "gradle.properties", + "app/build.gradle.kts", + "app/src/main/AndroidManifest.xml", + "app/src/main/java/com/perry/app/PerryActivity.kt", + "app/src/main/java/com/perry/app/PerryBridge.kt", + "app/src/main/java/com/perry/app/PerryMediaSessionCallback.java", + "app/src/main/res/values/themes.xml", + "app/src/main/res/drawable/splash_background.xml", + ] { + assert!(destination.join(relative).is_file(), "missing {relative}"); + } + for (relative, bytes) in FILES { + assert_eq!( + fs::read(destination.join(relative)).unwrap(), + *bytes, + "{relative}" + ); + } + assert!(!destination.join("local.properties").exists()); + assert!(!destination.join("app/src/main/jniLibs").exists()); + let manifest = + fs::read_to_string(destination.join("app/src/main/AndroidManifest.xml")).unwrap(); + assert!(manifest.contains("PerryActivity")); + let gradle = fs::read_to_string(destination.join("app/build.gradle.kts")).unwrap(); + assert!(gradle.contains("applicationId = \"com.perry.template\"")); + } + + #[test] + fn bundled_android_template_matches_checked_in_sources() { + // Every embedded file must match the source tree byte-for-byte. This + // catches lossy string conversions and platform newline rewriting. + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../perry-ui-android/template"); + for (relative, contents) in FILES { + assert_eq!( + fs::read(root.join(relative)).unwrap(), + *contents, + "{relative}" + ); + } + } +} + +// Run the build-time collector's regression cases in the CLI test harness. +#[cfg(test)] +#[allow(dead_code)] +#[path = "../../../build/android_template.rs"] +mod embedding; diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index 081dfa2261..521b9156ab 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -10,6 +10,7 @@ use super::compile::{CompileArgs, CompileResult}; use crate::{OutputFormat, Platform}; mod android; +mod android_template; mod devices; mod entry; mod launch; diff --git a/docs/src/platforms/android.md b/docs/src/platforms/android.md index 62b5d48a28..efff7277f4 100644 --- a/docs/src/platforms/android.md +++ b/docs/src/platforms/android.md @@ -17,6 +17,11 @@ Perry compiles TypeScript apps for Android using JNI (Java Native Interface). perry app.ts -o app --target android ``` +Local Android and Wear OS runs extract the Gradle project and Kotlin/Java bridge +from the Perry executable. Release installs do not need a Perry source checkout +or `PERRY_WORKSPACE_ROOT` to obtain this template. The local build still requires +the Android SDK, Gradle, and Java toolchain. + ## UI Toolkit Perry maps UI widgets to Android views via JNI: