Skip to main content

cargo_zng/res/built_in/
apk.rs

1use std::process::Command;
2
3use super::*;
4
5const APK_HELP: &str = r#"
6Build an Android APK from a staging directory
7
8The expected file system layout:
9
10| apk/
11| ├── lib/
12| |   └── arm64-v8a
13| |       └── my-app.so
14| ├── assets/
15| |   └── res
16| |       └── zng-res.txt
17| ├── res/
18| |   └── android-res
19| └── AndroidManifest.xml
20| my-app.zr-apk
21
22Both 'apk/' and 'my-app.zr-apk' will be replaced with the built my-app.apk
23
24Expected .zr-apk file content:
25
26| # Relative path to the staging directory. If not set uses ./apk if it exists
27| # or the parent dir .. if it is named something.apk
28| apk-dir = ./apk
29|
30| # Sign using the debug key. Note that if ZR_APK_KEYSTORE or ZR_APK_KEY_ALIAS are not
31| # set the APK is also signed using the debug key.
32| debug = true
33|
34| # Don't sign and don't zipalign the APK. This outputs an incomplete package that
35| # cannot be installed, but can be modified such as custom linking and signing.
36| raw = true
37|
38| # Don't tar assets. By default `assets/res` are packed as `assets/res.tar`
39| # for use with `android_install_res`.
40| tar-assets-res = false
41
42APK signing is configured using these environment variables:
43
44ZR_APK_KEYSTORE - path to the private .keystore file
45ZR_APK_KEYSTORE_PASS - keystore file password
46ZR_APK_KEY_ALIAS - key name in the keystore
47ZR_APK_KEY_PASS - key password
48"#;
49pub(super) fn apk() {
50    help(APK_HELP);
51    if std::env::var(ZR_FINAL).is_err() {
52        println!("zng-res::on-final=");
53        return;
54    }
55
56    // read config
57    let mut apk_dir = String::new();
58    let mut debug = false;
59    let mut raw = false;
60    let mut tar_assets = true;
61    for line in read_lines(&path(ZR_REQUEST)) {
62        let (ln, line) = line.unwrap_or_else(|e| fatal!("error reading .zr-apk request, {e}"));
63        if let Some((key, value)) = line.split_once('=') {
64            let key = key.trim();
65            let value = value.trim();
66
67            let bool_value = || match value {
68                "true" => true,
69                "false" => false,
70                _ => {
71                    error!("unexpected value, line {ln}\n   {line}");
72                    false
73                }
74            };
75            match key {
76                "apk-dir" => apk_dir = value.to_owned(),
77                "debug" => debug = bool_value(),
78                "raw" => raw = bool_value(),
79                "tar-assets" => tar_assets = bool_value(),
80                _ => error!("unknown key, line {ln}\n   {line}"),
81            }
82        } else {
83            error!("syntax error, line {ln}\n{line}");
84        }
85    }
86    let mut keystore = PathBuf::from(env::var("ZR_APK_KEYSTORE").unwrap_or_default());
87    let mut keystore_pass = env::var("ZR_APK_KEYSTORE_PASS").unwrap_or_default();
88    let mut key_alias = env::var("ZR_APK_KEY_ALIAS").unwrap_or_default();
89    let mut key_pass = env::var("ZR_APK_KEY_PASS").unwrap_or_default();
90    if keystore.as_os_str().is_empty() || key_alias.is_empty() {
91        debug = true;
92    }
93
94    let mut apk_folder = path(ZR_TARGET_DD);
95    let output_file;
96    if apk_dir.is_empty() {
97        let apk = apk_folder.join("apk");
98        if apk.exists() {
99            apk_folder = apk;
100            output_file = path(ZR_TARGET).with_extension("apk");
101        } else if apk_folder.extension().map(|e| e.eq_ignore_ascii_case("apk")).unwrap_or(false) {
102            output_file = apk_folder.clone();
103        } else {
104            fatal!("missing ./apk")
105        }
106    } else {
107        apk_folder = apk_folder.join(apk_dir);
108        if !apk_folder.is_dir() {
109            fatal!("{} not found or not a directory", apk_folder.display());
110        }
111        output_file = path(ZR_TARGET).with_extension("apk");
112    }
113    let apk_folder = apk_folder;
114
115    // find <sdk>/build-tools
116    let android_home = match env::var("ANDROID_HOME") {
117        Ok(h) if !h.is_empty() => h,
118        _ => fatal!("please set ANDROID_HOME to the android-sdk dir"),
119    };
120    let build_tools = Path::new(&android_home).join("build-tools/");
121    let mut best_build = None;
122    let mut best_version = semver::Version::new(0, 0, 0);
123
124    #[cfg(not(windows))]
125    const AAPT2_NAME: &str = "aapt2";
126    #[cfg(windows)]
127    const AAPT2_NAME: &str = "aapt2.exe";
128
129    for dir in fs::read_dir(build_tools).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/, {e}")) {
130        let dir = dir
131            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/build-tools/ entry, {e}"))
132            .path();
133
134        if let Some(ver) = dir
135            .file_name()
136            .and_then(|f| f.to_str())
137            .and_then(|f| semver::Version::parse(f).ok())
138            && ver > best_version
139            && dir.join(AAPT2_NAME).exists()
140        {
141            best_build = Some(dir);
142            best_version = ver;
143        }
144    }
145    let build_tools = match best_build {
146        Some(p) => p,
147        None => fatal!("cannot find $ANDROID_HOME/build-tools/<version>/{AAPT2_NAME}"),
148    };
149    let aapt2_path = build_tools.join(AAPT2_NAME);
150
151    // temp target dir
152    let temp_dir = apk_folder.with_extension("apk.tmp");
153    let _ = fs::remove_dir_all(&temp_dir);
154    fs::create_dir(&temp_dir).unwrap_or_else(|e| fatal!("cannot create {}, {e}", temp_dir.display()));
155
156    // tar assets
157    let assets = apk_folder.join("assets");
158    let assets_res = assets.join("res");
159    if tar_assets && assets_res.exists() {
160        let tar_path = assets.join("res.tar");
161        let r = Command::new("tar")
162            .arg("-cf")
163            .arg(&tar_path)
164            .arg("res")
165            .current_dir(&assets)
166            .status();
167        match r {
168            Ok(s) => {
169                if !s.success() {
170                    fatal!("tar failed")
171                }
172            }
173            Err(e) => fatal!("cannot run 'tar', {e}"),
174        }
175        if let Err(e) = fs::remove_dir_all(&assets_res) {
176            fatal!("failed tar-assets-res cleanup, {e}")
177        }
178    }
179
180    // build resources
181    let compiled_res = temp_dir.join("compiled_res.zip");
182    let res = apk_folder.join("res");
183    if res.exists() {
184        let mut aapt2 = Command::new(&aapt2_path);
185        aapt2.arg("compile").arg("-o").arg(&compiled_res).arg("--dir").arg(res);
186
187        if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
188            fatal!("resources build failed");
189        }
190    }
191
192    let manifest_path = apk_folder.join("AndroidManifest.xml");
193    let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| fatal!("cannot read AndroidManifest.xml, {e}"));
194    let manifest: AndroidManifest = quick_xml::de::from_str(&manifest).unwrap_or_else(|e| fatal!("error parsing AndroidManifest.xml, {e}"));
195
196    // find <sdk>/platforms
197    let platforms = Path::new(&android_home).join("platforms");
198    let mut best_platform = None;
199    let mut best_version = 0;
200    for dir in fs::read_dir(platforms).unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/, {e}")) {
201        let dir = dir
202            .unwrap_or_else(|e| fatal!("cannot read $ANDROID_HOME/platforms/ entry, {e}"))
203            .path();
204
205        if let Some(ver) = dir
206            .file_name()
207            .and_then(|f| f.to_str())
208            .and_then(|f| f.strip_prefix("android-"))
209            .and_then(|f| f.parse().ok())
210            && manifest.uses_sdk.matches(ver)
211            && ver > best_version
212            && dir.join("android.jar").exists()
213        {
214            best_platform = Some(dir);
215            best_version = ver;
216        }
217    }
218    let platform = match best_platform {
219        Some(p) => p,
220        None => fatal!("cannot find $ANDROID_HOME/platforms/<version>/android.jar"),
221    };
222
223    // make apk (link)
224    let apk_path = temp_dir.join("output.apk");
225    let mut aapt2 = Command::new(&aapt2_path);
226    aapt2
227        .arg("link")
228        .arg("-o")
229        .arg(&apk_path)
230        .arg("--manifest")
231        .arg(manifest_path)
232        .arg("-I")
233        .arg(platform.join("android.jar"));
234    if compiled_res.exists() {
235        aapt2.arg(&compiled_res);
236    }
237    if assets.exists() {
238        aapt2.arg("-A").arg(&assets);
239    }
240    if aapt2.status().map(|s| !s.success()).unwrap_or(true) {
241        fatal!("apk linking failed");
242    }
243
244    // add libs
245    let aapt_path = build_tools.join("aapt");
246    for lib in ::glob::glob(apk_folder.join("lib/*/*.so").display().to_string().as_str()).unwrap() {
247        let lib = lib.unwrap_or_else(|e| fatal!("error searching libs, {e}"));
248
249        let lib = lib.display().to_string().replace('\\', "/");
250        let lib = &lib[lib.rfind("/lib/").unwrap() + 1..];
251
252        let mut aapt = Command::new(&aapt_path);
253        aapt.arg("add").arg(&apk_path).arg(lib).current_dir(&apk_folder);
254        if aapt.status().map(|s| !s.success()).unwrap_or(true) {
255            fatal!("apk linking failed");
256        }
257    }
258
259    let final_apk = if raw {
260        apk_path
261    } else {
262        // align
263        let aligned_apk_path = temp_dir.join("output-aligned.apk");
264        let zipalign_path = build_tools.join("zipalign");
265        let mut zipalign = Command::new(zipalign_path);
266        zipalign.arg("-v").arg("4").arg(apk_path).arg(&aligned_apk_path);
267        if zipalign.status().map(|s| !s.success()).unwrap_or(true) {
268            fatal!("zipalign failed");
269        }
270
271        // sign
272        let signed_apk_path = temp_dir.join("output-signed.apk");
273        if debug {
274            let dirs = directories::BaseDirs::new().unwrap_or_else(|| fatal!("cannot fine $HOME"));
275            keystore = dirs.home_dir().join(".android/debug.keystore");
276            keystore_pass = "android".to_owned();
277            key_alias = "androiddebugkey".to_owned();
278            key_pass = "android".to_owned();
279            if !keystore.exists() {
280                // generate debug.keystore
281                let _ = fs::create_dir_all(keystore.parent().unwrap());
282                let keytool_path = Path::new(&env::var("JAVA_HOME").expect("please set JAVA_HOME")).join("bin/keytool");
283                let mut keytool = Command::new(&keytool_path);
284                keytool
285                    .arg("-genkey")
286                    .arg("-v")
287                    .arg("-keystore")
288                    .arg(&keystore)
289                    .arg("-storepass")
290                    .arg(&keystore_pass)
291                    .arg("-alias")
292                    .arg(&key_alias)
293                    .arg("-keypass")
294                    .arg(&key_pass)
295                    .arg("-keyalg")
296                    .arg("RSA")
297                    .arg("-keysize")
298                    .arg("2048")
299                    .arg("-validity")
300                    .arg("10000")
301                    .arg("-dname")
302                    .arg("CN=Android Debug,O=Android,C=US")
303                    .arg("-storetype")
304                    .arg("pkcs12");
305
306                match keytool.status() {
307                    Ok(s) => {
308                        if !s.success() {
309                            fatal!("keytool failed generating debug keys");
310                        }
311                    }
312                    Err(e) => fatal!("cannot run '{}', {e}", keytool_path.display()),
313                }
314            }
315        }
316
317        #[cfg(not(windows))]
318        const APKSIGNER_NAME: &str = "apksigner";
319        #[cfg(windows)]
320        const APKSIGNER_NAME: &str = "apksigner.bat";
321
322        let apksigner_path = build_tools.join(APKSIGNER_NAME);
323        let mut apksigner = Command::new(&apksigner_path);
324        apksigner
325            .arg("sign")
326            .arg("--ks")
327            .arg(keystore)
328            .arg("--ks-pass")
329            .arg(format!("pass:{keystore_pass}"))
330            .arg("--ks-key-alias")
331            .arg(key_alias)
332            .arg("--key-pass")
333            .arg(format!("pass:{key_pass}"))
334            .arg("--out")
335            .arg(&signed_apk_path)
336            .arg(&aligned_apk_path);
337
338        match apksigner.status() {
339            Ok(s) => {
340                if !s.success() {
341                    fatal!("apksigner failed")
342                }
343            }
344            Err(e) => fatal!("cannot run '{}', {e}", apksigner_path.display()),
345        }
346        signed_apk_path
347    };
348
349    // finalize
350    fs::remove_dir_all(&apk_folder).unwrap_or_else(|e| fatal!("apk folder cleanup failed, {e}"));
351    fs::rename(final_apk, output_file).unwrap_or_else(|e| fatal!("cannot copy built apk to final place, {e}"));
352    fs::remove_dir_all(&temp_dir).unwrap_or_else(|e| fatal!("temp dir cleanup failed, {e}"));
353    let _ = fs::remove_file(path(ZR_TARGET));
354}
355#[derive(serde::Deserialize)]
356#[serde(rename = "manifest")]
357struct AndroidManifest {
358    #[serde(rename = "uses-sdk")]
359    #[serde(default)]
360    pub uses_sdk: AndroidSdk,
361}
362#[derive(Default, serde::Deserialize)]
363#[serde(rename = "uses-sdk")]
364struct AndroidSdk {
365    #[serde(rename(serialize = "android:minSdkVersion"))]
366    pub min_sdk_version: Option<u32>,
367    #[serde(rename(serialize = "android:targetSdkVersion"))]
368    pub target_sdk_version: Option<u32>,
369    #[serde(rename(serialize = "android:maxSdkVersion"))]
370    pub max_sdk_version: Option<u32>,
371}
372impl AndroidSdk {
373    pub fn matches(&self, version: u32) -> bool {
374        if let Some(v) = self.target_sdk_version {
375            return v == version;
376        }
377        if let Some(m) = self.min_sdk_version
378            && version < m
379        {
380            return false;
381        }
382        if let Some(m) = self.max_sdk_version
383            && version > m
384        {
385            return false;
386        }
387        true
388    }
389}