use super::*;
const TAURI_MANIFEST: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.demo"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name=".MainActivity" android:exported="true" />
</application>
</manifest>
"#;
fn generated(label: &str, manifest: &str) -> std::path::PathBuf {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"rahti-native-android-{label}-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("app/src/main")).unwrap();
std::fs::write(root.join("app/src/main/AndroidManifest.xml"), manifest).unwrap();
root
}
fn manifest_of(root: &std::path::Path) -> String {
std::fs::read_to_string(root.join("app/src/main/AndroidManifest.xml")).unwrap()
}
#[test]
fn the_loopback_server_is_reachable_from_the_webview() {
let root = generated("patch", TAURI_MANIFEST);
crate::run::allow_loopback_cleartext(&root).expect("the patch applies");
let manifest = manifest_of(&root);
assert!(
manifest.contains(r#"android:networkSecurityConfig="@xml/rahti_network_security_config""#),
"{manifest}"
);
let config = std::fs::read_to_string(
root.join("app/src/main/res/xml/rahti_network_security_config.xml"),
)
.expect("the network security config");
assert!(config.contains("<domain includeSubdomains=\"false\">127.0.0.1</domain>"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn cleartext_stays_refused_for_everything_else() {
let root = generated("scope", TAURI_MANIFEST);
crate::run::allow_loopback_cleartext(&root).expect("the patch applies");
let config = std::fs::read_to_string(
root.join("app/src/main/res/xml/rahti_network_security_config.xml"),
)
.unwrap();
assert!(
config.contains(r#"<base-config cleartextTrafficPermitted="false" />"#),
"{config}"
);
assert!(
manifest_of(&root).contains(r#"android:usesCleartextTraffic="${usesCleartextTraffic}""#)
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn applying_it_twice_adds_one_attribute() {
let root = generated("twice", TAURI_MANIFEST);
crate::run::allow_loopback_cleartext(&root).expect("a first run");
let once = manifest_of(&root);
crate::run::allow_loopback_cleartext(&root).expect("a second run");
assert_eq!(
manifest_of(&root),
once,
"the second run changed the manifest"
);
assert_eq!(once.matches("networkSecurityConfig").count(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_manifest_this_tool_does_not_recognise_is_reported_rather_than_guessed_at() {
let root = generated(
"unknown",
"<?xml version=\"1.0\"?>\n<manifest><application /></manifest>\n",
);
let error = crate::run::allow_loopback_cleartext(&root).expect_err("no anchor");
assert!(
error.to_string().contains("networkSecurityConfig"),
"the error does not say what to add by hand: {error}"
);
let _ = std::fs::remove_dir_all(&root);
}
const TAURI_GRADLE: &str = r#"import java.util.Properties
plugins {
id("com.android.application")
}
android {
compileSdk = 36
buildTypes {
getByName("debug") {
isDebuggable = true
}
getByName("release") {
isMinifyEnabled = true
}
}
}
"#;
struct Signing(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
static SIGNING_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const SIGNING_VARS: [&str; 4] = [
"RAHTI_NATIVE_ANDROID_KEYSTORE",
"RAHTI_NATIVE_ANDROID_KEYSTORE_PASSWORD",
"RAHTI_NATIVE_ANDROID_KEY_ALIAS",
"RAHTI_NATIVE_ANDROID_KEY_PASSWORD",
];
impl Signing {
fn set(keystore: &str) -> Self {
let held = Signing::none();
unsafe {
std::env::set_var("RAHTI_NATIVE_ANDROID_KEYSTORE", keystore);
std::env::set_var("RAHTI_NATIVE_ANDROID_KEYSTORE_PASSWORD", "storepass");
std::env::set_var("RAHTI_NATIVE_ANDROID_KEY_ALIAS", "release");
std::env::set_var("RAHTI_NATIVE_ANDROID_KEY_PASSWORD", "keypass");
}
held
}
fn none() -> Self {
let guard = SIGNING_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
for name in SIGNING_VARS {
std::env::remove_var(name);
}
}
Signing(guard)
}
}
impl Drop for Signing {
fn drop(&mut self) {
unsafe {
for name in SIGNING_VARS {
std::env::remove_var(name);
}
}
}
}
fn with_gradle(label: &str) -> std::path::PathBuf {
let root = generated(label, TAURI_MANIFEST);
std::fs::write(root.join("app/build.gradle.kts"), TAURI_GRADLE).unwrap();
root
}
#[test]
fn a_release_key_reaches_the_gradle_project() {
let root = with_gradle("signing");
let _signing = Signing::set("C:\\keys\\release.jks");
crate::run::configure_signing(&root).expect("the signing patch applies");
let gradle = std::fs::read_to_string(root.join("app/build.gradle.kts")).unwrap();
assert!(gradle.contains("signingConfigs {"), "{gradle}");
assert!(
gradle.contains("signingConfig = signingConfigs.getByName(\"release\")"),
"{gradle}"
);
let release_at = gradle.find("getByName(\"release\")").unwrap();
let applied_at = gradle.find("signingConfig = signingConfigs").unwrap();
assert!(applied_at > release_at, "{gradle}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_key_details_go_only_to_the_ignored_properties_file() {
let root = with_gradle("signing-secrets");
let _signing = Signing::set("C:\\keys\\release.jks");
crate::run::configure_signing(&root).expect("the signing patch applies");
let properties =
std::fs::read_to_string(root.join("keystore.properties")).expect("the properties file");
assert!(properties.contains("keyAlias=release"), "{properties}");
assert!(
properties.contains("storePassword=storepass"),
"{properties}"
);
assert!(
properties.contains("storeFile=C:/keys/release.jks"),
"{properties}"
);
let gradle = std::fs::read_to_string(root.join("app/build.gradle.kts")).unwrap();
for secret in ["storepass", "keypass", "release.jks"] {
assert!(
!gradle.contains(secret),
"the Gradle file carries `{secret}`, which is committed"
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn applying_the_signing_patch_twice_wires_it_once() {
let root = with_gradle("signing-twice");
let _signing = Signing::set("C:\\keys\\release.jks");
crate::run::configure_signing(&root).expect("a first run");
let once = std::fs::read_to_string(root.join("app/build.gradle.kts")).unwrap();
crate::run::configure_signing(&root).expect("a second run");
let twice = std::fs::read_to_string(root.join("app/build.gradle.kts")).unwrap();
assert_eq!(once, twice);
assert_eq!(once.matches("signingConfigs {").count(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn no_keystore_configured_changes_nothing() {
let root = with_gradle("signing-absent");
let _signing = Signing::none();
crate::run::configure_signing(&root).expect("nothing to do");
assert_eq!(
std::fs::read_to_string(root.join("app/build.gradle.kts")).unwrap(),
TAURI_GRADLE
);
assert!(!root.join("keystore.properties").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_half_configured_key_is_refused_rather_than_producing_an_unsigned_release() {
let root = with_gradle("signing-partial");
let _signing = Signing::none();
unsafe {
std::env::set_var("RAHTI_NATIVE_ANDROID_KEYSTORE", "C:\\keys\\release.jks");
}
let error = crate::run::configure_signing(&root).expect_err("a half-configured key");
assert!(error.to_string().contains("unsigned"), "{error}");
let _ = std::fs::remove_dir_all(&root);
}
fn with_template_icons(label: &str) -> (std::path::PathBuf, crate::project::Project) {
let root = generated(label, TAURI_MANIFEST);
for density in ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"] {
let dir = root.join(format!("app/src/main/res/mipmap-{density}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ic_launcher.webp"), b"the template's robot").unwrap();
}
let project_root = root.join("project");
std::fs::create_dir_all(project_root.join("src")).unwrap();
std::fs::write(project_root.join("rahti.config.json"), "{}").unwrap();
std::fs::write(
project_root.join("Cargo.toml"),
"[package]
name = \"app\"
version = \"1.0.0\"
",
)
.unwrap();
let icons = project_root.join("native/icons");
for icon in crate::icons::ANDROID {
let target = icons.join(icon.path);
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, icon.bytes).unwrap();
}
let project = crate::project::Project::at(&project_root).expect("a project");
(root, project)
}
#[test]
fn the_applications_launcher_icons_replace_the_templates() {
let (root, project) = with_template_icons("icons");
crate::run::install_android_icons(&project, &root).expect("the icons install");
let res = root.join("app/src/main/res");
for density in ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"] {
let png = res.join(format!("mipmap-{density}/ic_launcher.png"));
assert!(png.is_file(), "mipmap-{density} did not get an icon");
assert_ne!(
std::fs::read(&png).unwrap(),
b"the template's robot".to_vec()
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_templates_file_for_the_same_resource_name_is_removed() {
let (root, project) = with_template_icons("icons-clash");
crate::run::install_android_icons(&project, &root).expect("the icons install");
let res = root.join("app/src/main/res");
for density in ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"] {
assert!(
!res.join(format!("mipmap-{density}/ic_launcher.webp"))
.exists(),
"mipmap-{density} still has the template's .webp beside ours"
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_adaptive_icon_xml_and_its_background_are_installed() {
let (root, project) = with_template_icons("icons-adaptive");
crate::run::install_android_icons(&project, &root).expect("the icons install");
let res = root.join("app/src/main/res");
assert!(res.join("mipmap-anydpi-v26/ic_launcher.xml").is_file());
assert!(res.join("values/ic_launcher_background.xml").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn installing_the_icons_twice_writes_them_once() {
let (root, project) = with_template_icons("icons-twice");
crate::run::install_android_icons(&project, &root).expect("a first run");
let png = root.join("app/src/main/res/mipmap-mdpi/ic_launcher.png");
let stamp = std::fs::metadata(&png).unwrap().modified().unwrap();
crate::run::install_android_icons(&project, &root).expect("a second run");
assert_eq!(
std::fs::metadata(&png).unwrap().modified().unwrap(),
stamp,
"the second run rewrote an unchanged icon"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_project_with_no_icon_tree_is_left_alone() {
let root = generated("icons-absent", TAURI_MANIFEST);
let project_root = root.join("project");
std::fs::create_dir_all(&project_root).unwrap();
std::fs::write(project_root.join("rahti.config.json"), "{}").unwrap();
std::fs::write(
project_root.join("Cargo.toml"),
"[package]
name = \"app\"
version = \"1.0.0\"
",
)
.unwrap();
let project = crate::project::Project::at(&project_root).expect("a project");
crate::run::install_android_icons(&project, &root).expect("nothing to do");
let _ = std::fs::remove_dir_all(&root);
}