Skip to main content

andysu_cli/
hostapk.rs

1//! Build the frozen host APK from any finished APK (`andysu split --host-template ...`).
2//!
3//! The original APK keeps its identity (package, version, label, icon, resources, signing key)
4//! but its code becomes the AndySU host from a prebuilt template (`classes*.dex` +
5//! `lib/<abi>/libandysu_*.so`). Its own components are mapped onto host stubs, keeping their
6//! attributes and intent filters; everything they ran is in the payload.
7
8use std::collections::BTreeMap;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::axml::{self, attr, Attr, Element, Node, Value, TYPE_INT_BOOLEAN, TYPE_REFERENCE};
14use crate::{read, R};
15
16const THEME_TRANSLUCENT_NO_TITLE: u32 = 0x0103_0010;
17const HOST_PERMISSIONS: [&str; 3] = [
18    "android.permission.INTERNET",
19    "android.permission.ACCESS_NETWORK_STATE",
20    "android.permission.RECEIVE_BOOT_COMPLETED",
21];
22const STUB_ACTIVITIES: [&str; 4] = [
23    "com.andysu.host.HostActivity",
24    "com.andysu.host.StubActivity1",
25    "com.andysu.host.StubActivity2",
26    "com.andysu.host.StubActivity3",
27];
28const STUB_SERVICES: [&str; 2] = ["com.andysu.host.StubService0", "com.andysu.host.StubService1"];
29const STUB_RECEIVERS: [&str; 2] = ["com.andysu.host.StubReceiver0", "com.andysu.host.StubReceiver1"];
30
31pub struct HostOptions {
32    pub template: PathBuf,
33    pub config: PathBuf,
34    pub factory: PathBuf,
35    pub out: PathBuf,
36    pub keystore: Option<PathBuf>,
37    pub ks_pass: Option<String>,
38    pub key_alias: Option<String>,
39    pub cleartext: bool,
40    pub transparent: bool,
41    pub payload_has_assets: bool,
42}
43
44#[derive(serde::Serialize, Debug, Default)]
45pub struct HostReport {
46    pub apk: String,
47    pub signed: bool,
48    /// original component -> stub it now runs as
49    pub stubs: BTreeMap<String, String>,
50    pub dropped: Vec<String>,
51    pub abis: Vec<String>,
52    pub warnings: Vec<String>,
53}
54
55fn b(v: bool) -> Value {
56    Value::Typed { dtype: TYPE_INT_BOOLEAN, data: if v { u32::MAX } else { 0 } }
57}
58
59fn s(v: &str) -> Value {
60    Value::Str(v.into())
61}
62
63fn is_launcher(e: &Element) -> bool {
64    e.elements().filter(|f| f.name == "intent-filter").any(|f| {
65        let has = |tag: &str, v: &str| f.elements().any(|x| x.name == tag && x.android_name() == Some(v));
66        has("action", "android.intent.action.MAIN") && has("category", "android.intent.category.LAUNCHER")
67    })
68}
69
70fn full_name(pkg: &str, n: &str) -> String {
71    if n.starts_with('.') {
72        format!("{pkg}{n}")
73    } else if !n.contains('.') {
74        format!("{pkg}.{n}")
75    } else {
76        n.to_string()
77    }
78}
79
80/// Rewrite the manifest; returns (new manifest bytes, stub map, dropped components).
81/// Transparent mode: keep every component; the host's AppComponentFactory gives the platform the
82/// payload's class loader, and an init provider starts AndySU before Application.onCreate.
83pub fn transform_transparent(orig: &[u8], rep: &mut HostReport, cleartext: bool, needs_assets: bool) -> R<Vec<u8>> {
84    let mut doc = axml::parse(orig)?;
85    let pkg = doc
86        .root
87        .attrs
88        .iter()
89        .find(|a| a.name == "package")
90        .and_then(|a| a.str())
91        .ok_or("manifest has no package")?
92        .to_string();
93    let have: Vec<String> = doc
94        .root
95        .elements()
96        .filter(|e| e.name == "uses-permission")
97        .filter_map(|e| e.android_name().map(String::from))
98        .collect();
99    let app_pos = doc
100        .root
101        .children
102        .iter()
103        .position(|n| matches!(n, Node::Element(e) if e.name == "application"))
104        .ok_or("manifest has no <application>")?;
105    for (i, p) in HOST_PERMISSIONS.iter().filter(|p| !have.iter().any(|h| h == *p)).enumerate() {
106        doc.root.children.insert(
107            app_pos + i,
108            Node::Element(Element::new("uses-permission", vec![Attr::android("name", attr::NAME, s(p))])),
109        );
110    }
111    // AppComponentFactory needs Android 9; payload assets through ResourcesLoader need Android 11.
112    const MIN_SDK: u32 = 0x0101_020c;
113    let need = if needs_assets { 30 } else { 28 };
114    if let Some(us) = doc.root.child_mut("uses-sdk") {
115        let cur = match us.attr(MIN_SDK).map(|a| &a.value) {
116            Some(Value::Typed { data, .. }) => *data,
117            Some(Value::Str(x)) => x.parse().unwrap_or(1),
118            None => 1,
119        };
120        if cur < need {
121            us.set(Attr::android("minSdkVersion", MIN_SDK, Value::Typed { dtype: axml::TYPE_INT_DEC, data: need }));
122            rep.warnings.push(format!("transparent mode raises minSdkVersion from {cur} to {need}"));
123        }
124    }
125    let app = doc.root.child_mut("application").unwrap();
126    if let Some(f) = app.attr(attr::APP_COMPONENT_FACTORY).and_then(|a| a.str()) {
127        rep.warnings.push(format!("replacing the app's AppComponentFactory {f}"));
128    }
129    app.set(Attr::android(
130        "appComponentFactory",
131        attr::APP_COMPONENT_FACTORY,
132        s("com.andysu.host.AndySUComponentFactory"),
133    ));
134    app.remove(attr::HAS_CODE);
135    // Libraries are stored uncompressed and 16 KB aligned and mapped straight from the APK.
136    app.set(Attr::android("extractNativeLibs", attr::EXTRACT_NATIVE_LIBS, b(false)));
137    if cleartext {
138        const USES_CLEARTEXT_TRAFFIC: u32 = 0x0101_04ec;
139        app.set(Attr::android("usesCleartextTraffic", USES_CLEARTEXT_TRAFFIC, b(true)));
140        rep.warnings.push("--cleartext: plain HTTP allowed (testing only)".into());
141    }
142    const INIT_ORDER: u32 = 0x0101_001a;
143    app.push(Element::new(
144        "provider",
145        vec![
146            Attr::android("name", attr::NAME, s("com.andysu.host.InitProvider")),
147            Attr::android("exported", attr::EXPORTED, b(false)),
148            Attr::android("authorities", attr::AUTHORITIES, s(&format!("{pkg}.andysu.init"))),
149            Attr::android("initOrder", INIT_ORDER, Value::Typed { dtype: axml::TYPE_INT_DEC, data: 10_000 }),
150        ],
151    ));
152    push_host_components(app, &pkg);
153    Ok(axml::write(&doc))
154}
155
156fn push_host_components(app: &mut Element, pkg: &str) {
157    app.push(Element::new(
158        "activity",
159        vec![
160            Attr::android("name", attr::NAME, s("com.andysu.host.PhoenixActivity")),
161            Attr::android("process", attr::PROCESS, s(":andysu_phoenix")),
162            Attr::android(
163                "theme",
164                attr::THEME,
165                Value::Typed { dtype: TYPE_REFERENCE, data: THEME_TRANSLUCENT_NO_TITLE },
166            ),
167            Attr::android("excludeFromRecents", attr::EXCLUDE_FROM_RECENTS, b(true)),
168            Attr::android("noHistory", attr::NO_HISTORY, b(true)),
169            Attr::android("exported", attr::EXPORTED, b(false)),
170        ],
171    ));
172    app.push(Element::new(
173        "service",
174        vec![
175            Attr::android("name", attr::NAME, s("com.andysu.host.UpdateJobService")),
176            Attr::android("permission", attr::PERMISSION, s("android.permission.BIND_JOB_SERVICE")),
177            Attr::android("exported", attr::EXPORTED, b(false)),
178        ],
179    ));
180    let mut recv = Element::new(
181        "receiver",
182        vec![
183            Attr::android("name", attr::NAME, s("com.andysu.host.ShellInstallReceiver")),
184            Attr::android("exported", attr::EXPORTED, b(false)),
185        ],
186    );
187    let mut f = Element::new("intent-filter", vec![]);
188    f.push(Element::new("action", vec![Attr::android("name", attr::NAME, s("com.andysu.host.SHELL_INSTALL_STATUS"))]));
189    recv.push(f);
190    app.push(recv);
191    app.push(Element::new(
192        "provider",
193        vec![
194            Attr::android("name", attr::NAME, s("com.andysu.host.StubFileProvider")),
195            Attr::android("exported", attr::EXPORTED, b(false)),
196            Attr::android("authorities", attr::AUTHORITIES, s(&format!("{pkg}.andysu.files"))),
197            Attr::android("grantUriPermissions", attr::GRANT_URI_PERMISSIONS, b(true)),
198        ],
199    ));
200}
201
202pub fn transform_manifest(orig: &[u8], rep: &mut HostReport, cleartext: bool) -> R<Vec<u8>> {
203    let mut doc = axml::parse(orig)?;
204    let pkg = doc
205        .root
206        .attrs
207        .iter()
208        .find(|a| a.name == "package")
209        .and_then(|a| a.str())
210        .ok_or("manifest has no package")?
211        .to_string();
212    // Permissions: add the host's normal ones.
213    let have: Vec<String> = doc
214        .root
215        .elements()
216        .filter(|e| e.name == "uses-permission")
217        .filter_map(|e| e.android_name().map(String::from))
218        .collect();
219    let app_pos = doc
220        .root
221        .children
222        .iter()
223        .position(|n| matches!(n, Node::Element(e) if e.name == "application"))
224        .ok_or("manifest has no <application>")?;
225    for (i, p) in HOST_PERMISSIONS.iter().filter(|p| !have.iter().any(|h| h == *p)).enumerate() {
226        doc.root.children.insert(
227            app_pos + i,
228            Node::Element(Element::new("uses-permission", vec![Attr::android("name", attr::NAME, s(p))])),
229        );
230    }
231    let app = doc.root.child_mut("application").unwrap();
232    app.set(Attr::android("name", attr::NAME, s("com.andysu.host.AndySUApplication")));
233    app.remove(attr::APP_COMPONENT_FACTORY);
234    app.remove(attr::HAS_CODE);
235    // Libraries are stored uncompressed and 16 KB aligned and mapped straight from the APK.
236    app.set(Attr::android("extractNativeLibs", attr::EXTRACT_NATIVE_LIBS, b(false)));
237    if cleartext {
238        const USES_CLEARTEXT_TRAFFIC: u32 = 0x0101_04ec;
239        app.set(Attr::android("usesCleartextTraffic", USES_CLEARTEXT_TRAFFIC, b(true)));
240        rep.warnings.push("--cleartext: plain HTTP allowed (testing only)".into());
241    }
242
243    // Activities: launcher first onto HostActivity, the rest onto stubs 1..3.
244    let mut acts: Vec<usize> = app
245        .children
246        .iter()
247        .enumerate()
248        .filter(|(_, n)| matches!(n, Node::Element(e) if e.name == "activity"))
249        .map(|(i, _)| i)
250        .collect();
251    acts.sort_by_key(|&i| if let Node::Element(e) = &app.children[i] { !is_launcher(e) } else { true });
252    let mut renamed: BTreeMap<String, String> = BTreeMap::new();
253    let mut drop_idx = vec![];
254    let (mut ai, mut si, mut ri) = (0usize, 0usize, 0usize);
255    for &i in &acts {
256        let Node::Element(e) = &mut app.children[i] else { continue };
257        let old = full_name(&pkg, e.android_name().unwrap_or(""));
258        if ai >= STUB_ACTIVITIES.len() {
259            return Err(format!(
260                "the app has more than {} activities; this needs more stub activities in the host",
261                STUB_ACTIVITIES.len()
262            ));
263        }
264        e.set(Attr::android("name", attr::NAME, s(STUB_ACTIVITIES[ai])));
265        renamed.insert(old, STUB_ACTIVITIES[ai].into());
266        ai += 1;
267    }
268    for (i, n) in app.children.iter_mut().enumerate() {
269        let Node::Element(e) = n else { continue };
270        let old = full_name(&pkg, e.android_name().unwrap_or(""));
271        match e.name.as_str() {
272            "service" | "receiver" => {
273                let (stubs, k): (&[&str], &mut usize) =
274                    if e.name == "service" { (&STUB_SERVICES, &mut si) } else { (&STUB_RECEIVERS, &mut ri) };
275                if *k < stubs.len() {
276                    e.set(Attr::android("name", attr::NAME, s(stubs[*k])));
277                    renamed.insert(old, stubs[*k].into());
278                    *k += 1;
279                } else {
280                    drop_idx.push(i);
281                    rep.dropped.push(old);
282                }
283            }
284            "provider" => {
285                drop_idx.push(i);
286                rep.dropped.push(old);
287            }
288            "activity-alias" => {
289                // targetActivity must follow the renamed activity.
290                const TARGET_ACTIVITY: u32 = 0x0101_0202;
291                if let Some(t) = e.attr(TARGET_ACTIVITY).and_then(|a| a.str()).map(|t| full_name(&pkg, t)) {
292                    if let Some(new) = renamed.get(&t) {
293                        let new = new.clone();
294                        e.set(Attr::android("targetActivity", TARGET_ACTIVITY, s(&new)));
295                    }
296                }
297            }
298            _ => {}
299        }
300    }
301    for i in drop_idx.into_iter().rev() {
302        app.children.remove(i);
303    }
304    if !rep.dropped.is_empty() {
305        rep.warnings.push(format!(
306            "{} component(s) cannot run from a host stub and were left out: {}. Payload code can do their work from Payload.start().",
307            rep.dropped.len(),
308            rep.dropped.join(", ")
309        ));
310    }
311    // Stubs the original did not use, and the host's own components. An app without a launcher
312    // activity gets one: the host's.
313    for (k, st) in STUB_ACTIVITIES.iter().enumerate().skip(ai) {
314        let launcher = k == 0;
315        let mut e = Element::new(
316            "activity",
317            vec![Attr::android("name", attr::NAME, s(st)), Attr::android("exported", attr::EXPORTED, b(launcher))],
318        );
319        if launcher {
320            let mut f = Element::new("intent-filter", vec![]);
321            f.push(Element::new("action", vec![Attr::android("name", attr::NAME, s("android.intent.action.MAIN"))]));
322            f.push(Element::new(
323                "category",
324                vec![Attr::android("name", attr::NAME, s("android.intent.category.LAUNCHER"))],
325            ));
326            e.push(f);
327        }
328        app.push(e);
329    }
330    for st in STUB_SERVICES.iter().skip(si) {
331        app.push(Element::new(
332            "service",
333            vec![Attr::android("name", attr::NAME, s(st)), Attr::android("exported", attr::EXPORTED, b(false))],
334        ));
335    }
336    for st in STUB_RECEIVERS.iter().skip(ri) {
337        app.push(Element::new(
338            "receiver",
339            vec![Attr::android("name", attr::NAME, s(st)), Attr::android("exported", attr::EXPORTED, b(false))],
340        ));
341    }
342    push_host_components(app, &pkg);
343    rep.stubs = renamed;
344    Ok(axml::write(&doc))
345}
346
347fn is_signature(n: &str) -> bool {
348    n.starts_with("META-INF/") && [".SF", ".RSA", ".EC", ".DSA", "MANIFEST.MF"].iter().any(|x| n.ends_with(x))
349}
350
351fn apksigner() -> Option<PathBuf> {
352    let sdk = std::env::var("ANDROID_HOME")
353        .ok()
354        .map(PathBuf::from)
355        .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join("Android/Sdk")))?;
356    let mut v: Vec<PathBuf> = std::fs::read_dir(sdk.join("build-tools"))
357        .ok()?
358        .flatten()
359        .map(|e| e.path().join("apksigner"))
360        .filter(|p| p.exists())
361        .collect();
362    v.sort();
363    v.pop()
364}
365
366pub fn build(orig_apk: &Path, o: &HostOptions) -> R<HostReport> {
367    let mut rep = HostReport::default();
368    let mut z =
369        zip::ZipArchive::new(std::fs::File::open(orig_apk).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
370    let mut tpl =
371        zip::ZipArchive::new(std::fs::File::open(&o.template).map_err(|e| format!("{}: {e}", o.template.display()))?)
372            .map_err(|e| e.to_string())?;
373    let mut manifest = vec![];
374    z.by_name("AndroidManifest.xml")
375        .map_err(|e| e.to_string())?
376        .read_to_end(&mut manifest)
377        .map_err(|e| e.to_string())?;
378    let new_manifest = if o.transparent {
379        transform_transparent(&manifest, &mut rep, o.cleartext, o.payload_has_assets)?
380    } else {
381        transform_manifest(&manifest, &mut rep, o.cleartext)?
382    };
383
384    let orig_abis: Vec<String> = {
385        let mut v: Vec<String> = z
386            .file_names()
387            .filter_map(|n| n.strip_prefix("lib/"))
388            .filter_map(|r| r.split('/').next())
389            .map(String::from)
390            .collect();
391        v.sort();
392        v.dedup();
393        v
394    };
395    let tpl_names: Vec<String> = tpl.file_names().map(String::from).collect();
396    let tpl_abis: Vec<String> = {
397        let mut v: Vec<String> = tpl_names
398            .iter()
399            .filter_map(|n| n.strip_prefix("lib/"))
400            .filter_map(|r| r.split('/').next())
401            .map(String::from)
402            .collect();
403        v.sort();
404        v.dedup();
405        v
406    };
407    rep.abis = if orig_abis.is_empty() {
408        tpl_abis.clone()
409    } else {
410        orig_abis.iter().filter(|a| tpl_abis.contains(a)).cloned().collect()
411    };
412    for a in &orig_abis {
413        if !tpl_abis.contains(a) {
414            rep.warnings.push(format!("the host template has no {a} build; that ABI is left out"));
415        }
416    }
417
418    let unsigned = o.out.with_extension("unsigned.apk");
419    {
420        let f = std::fs::File::create(&unsigned).map_err(|e| e.to_string())?;
421        let mut w = zip::ZipWriter::new(f);
422        let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
423        let stored4 = zip::write::SimpleFileOptions::default()
424            .compression_method(zip::CompressionMethod::Stored)
425            .with_alignment(4);
426        // Uncompressed and 16 KB aligned: mapped from the APK directly (extractNativeLibs=false),
427        // as Android 15+ devices with 16 KB pages require.
428        let stored16k = zip::write::SimpleFileOptions::default()
429            .compression_method(zip::CompressionMethod::Stored)
430            .with_alignment(16384);
431        w.start_file("AndroidManifest.xml", deflate).map_err(|e| e.to_string())?;
432        w.write_all(&new_manifest).map_err(|e| e.to_string())?;
433        for i in 0..z.len() {
434            let e = z.by_index_raw(i).map_err(|e| e.to_string())?;
435            let n = e.name().to_string();
436            drop(e);
437            if n == "resources.arsc" {
438                // Android 11+ requires it uncompressed and 4-byte aligned.
439                let mut data = vec![];
440                z.by_index(i).map_err(|e| e.to_string())?.read_to_end(&mut data).map_err(|e| e.to_string())?;
441                w.start_file(n.as_str(), stored4).map_err(|e| e.to_string())?;
442                w.write_all(&data).map_err(|e| e.to_string())?;
443            } else if n.starts_with("res/") && !n.ends_with('/') && !is_signature(&n) {
444                w.raw_copy_file(z.by_index_raw(i).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
445            }
446        }
447        for n in &tpl_names {
448            let keep = (n.starts_with("classes") && n.ends_with(".dex"))
449                || n.strip_prefix("lib/").is_some_and(|r| rep.abis.iter().any(|a| r.starts_with(&format!("{a}/"))));
450            if keep && !n.ends_with('/') {
451                let mut data = vec![];
452                tpl.by_name(n).map_err(|e| e.to_string())?.read_to_end(&mut data).map_err(|e| e.to_string())?;
453                w.start_file(n.as_str(), if n.ends_with(".so") { stored16k } else { deflate })
454                    .map_err(|e| e.to_string())?;
455                w.write_all(&data).map_err(|e| e.to_string())?;
456            }
457        }
458        w.start_file("assets/andysu/config.json", deflate).map_err(|e| e.to_string())?;
459        w.write_all(&read(&o.config)?).map_err(|e| e.to_string())?;
460        for rel in crate::pack::walk(&o.factory)? {
461            w.start_file(format!("assets/andysu-factory/{rel}"), deflate).map_err(|e| e.to_string())?;
462            w.write_all(&read(&o.factory.join(&rel))?).map_err(|e| e.to_string())?;
463        }
464        w.finish().map_err(|e| e.to_string())?;
465    }
466    match &o.keystore {
467        Some(ks) => {
468            let signer = apksigner().ok_or("apksigner not found (set ANDROID_HOME)")?;
469            let mut c = Command::new(signer);
470            c.args(["sign", "--ks"]).arg(ks);
471            if let Some(p) = &o.ks_pass {
472                c.args(["--ks-pass", p]);
473            }
474            if let Some(a) = &o.key_alias {
475                c.args(["--ks-key-alias", a]);
476            }
477            c.arg("--out").arg(&o.out).arg(&unsigned);
478            let r = c.output().map_err(|e| e.to_string())?;
479            if !r.status.success() {
480                return Err(format!("apksigner failed: {}", String::from_utf8_lossy(&r.stderr)));
481            }
482            let _ = std::fs::remove_file(&unsigned);
483            rep.signed = true;
484            rep.apk = o.out.display().to_string();
485        }
486        None => {
487            rep.apk = unsigned.display().to_string();
488            rep.warnings
489                .push("no --keystore: the host APK is unsigned; sign it with the app's own key (apksigner)".into());
490        }
491    }
492    Ok(rep)
493}