Skip to main content

andysu_cli/
split.rs

1//! `andysu split app.apk`: a finished APK becomes a payload directory (and, with the Gradle
2//! plugin or `--host`, a frozen host APK; see docs).
3//!
4//! The payload gets every `classes*.dex` (dex/), every `lib/<abi>/*.so` (lib/<abi>/), the assets
5//! (assets/), Java resources (dex/resources.jar), and a descriptor whose `needs` list what the
6//! original manifest asked for, so `andysu pack --host` can refuse payloads the host cannot run.
7
8use std::collections::BTreeMap;
9use std::io::Write;
10use std::path::PathBuf;
11
12use andysu_core::payload::{Descriptor, Entry, Needs};
13
14use crate::apk::Apk;
15use crate::{write, R};
16
17#[derive(clap::Args, Clone, Debug)]
18pub struct SplitArgs {
19    /// The finished APK.
20    pub apk: PathBuf,
21    /// Output directory (receives payload/ and andysu-host.json).
22    #[arg(long, short)]
23    pub out: PathBuf,
24    /// Application id (default: the APK's package).
25    #[arg(long)]
26    pub app: Option<String>,
27    /// Payload version (default: the APK's versionName).
28    #[arg(long)]
29    pub version: Option<String>,
30    /// JVM entry class implementing com.andysu.api.Payload.
31    #[arg(long)]
32    pub entry_class: Option<String>,
33    /// Native entry, LIB:SYMBOL (e.g. libgame:andysu_payload_v1).
34    #[arg(long)]
35    pub entry_native: Option<String>,
36    /// Host API level the payload needs.
37    #[arg(long, default_value_t = 1)]
38    pub host_api: u32,
39    /// Keep only these ABIs (default: all).
40    #[arg(long)]
41    pub abi: Vec<String>,
42    /// Also build the frozen host APK from this host template (andysu-host-template-<ver>.zip).
43    #[arg(long, requires = "config")]
44    pub host_template: Option<PathBuf>,
45    /// Host config.json (from `andysu host-config`).
46    #[arg(long)]
47    pub config: Option<PathBuf>,
48    /// Keystore for signing the host APK (the app's own key, so it installs over the original).
49    #[arg(long)]
50    pub keystore: Option<PathBuf>,
51    /// apksigner --ks-pass value, e.g. pass:secret or env:VAR.
52    #[arg(long)]
53    pub ks_pass: Option<String>,
54    #[arg(long)]
55    pub key_alias: Option<String>,
56    /// Allow plain-HTTP remotes (local testing only; real remotes use HTTPS).
57    #[arg(long)]
58    pub cleartext: bool,
59    /// Transparent mode (Android 9+): keep the app's components and code as they are; the host
60    /// loads them from the payload. No entry class needed; updates apply on the next start.
61    #[arg(long)]
62    pub transparent: bool,
63}
64
65#[derive(serde::Serialize, Debug, Default)]
66pub struct SplitReport {
67    pub payload: String,
68    pub app: String,
69    pub version: String,
70    pub dex: Vec<String>,
71    pub native: BTreeMap<String, Vec<String>>,
72    pub assets: usize,
73    pub java_resources: usize,
74    pub manifest: crate::apk::ManifestInfo,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub host: Option<crate::hostapk::HostReport>,
77}
78
79fn is_java_resource(n: &str) -> bool {
80    !(n.ends_with('/')
81        || n == "AndroidManifest.xml"
82        || n == "resources.arsc"
83        || n.starts_with("res/")
84        || n.starts_with("assets/")
85        || n.starts_with("lib/")
86        || n.starts_with("META-INF/")
87            && (n.ends_with(".SF")
88                || n.ends_with(".RSA")
89                || n.ends_with(".EC")
90                || n.ends_with(".DSA")
91                || n.ends_with("MANIFEST.MF"))
92        || (n.starts_with("classes") && n.ends_with(".dex") && !n.contains('/'))
93        || n.starts_with("stamp-cert-sha256"))
94}
95
96pub fn split(a: &SplitArgs) -> R<SplitReport> {
97    let mut apk = Apk::open(&a.apk)?;
98    let mi = apk.manifest()?;
99    let payload = a.out.join("payload");
100    if payload.exists() && std::fs::read_dir(&payload).map(|mut d| d.next().is_some()).unwrap_or(false) {
101        return Err(format!("{} is not empty", payload.display()));
102    }
103    let mut rep = SplitReport { payload: payload.display().to_string(), ..Default::default() };
104    let mut javares: Vec<(String, Vec<u8>)> = vec![];
105    for n in apk.names() {
106        if n.starts_with("classes") && n.ends_with(".dex") && !n.contains('/') {
107            write(&payload.join("dex").join(&n), &apk.read(&n)?)?;
108            rep.dex.push(format!("dex/{n}"));
109        } else if let Some(rest) = n.strip_prefix("lib/") {
110            let parts: Vec<&str> = rest.split('/').collect();
111            if parts.len() == 2
112                && parts[1].ends_with(".so")
113                && (a.abi.is_empty() || a.abi.iter().any(|x| x == parts[0]))
114            {
115                write(&payload.join(&n), &apk.read(&n)?)?;
116                rep.native.entry(parts[0].to_string()).or_default().push(parts[1].to_string());
117            }
118        } else if let Some(rest) = n.strip_prefix("assets/") {
119            if rest.is_empty() || n.ends_with('/') || rest.starts_with("andysu/") || rest.starts_with("andysu-factory/")
120            {
121                continue;
122            }
123            andysu_core::manifest::check_rel_path(&n).map_err(|e| e.to_string())?;
124            write(&payload.join(&n), &apk.read(&n)?)?;
125            rep.assets += 1;
126        } else if is_java_resource(&n) {
127            andysu_core::manifest::check_rel_path(&n).map_err(|e| e.to_string())?;
128            javares.push((n.clone(), apk.read(&n)?));
129        }
130    }
131    // dex order: classes.dex, classes2.dex, ...
132    rep.dex.sort_by_key(|p| p.trim_start_matches("dex/classes").trim_end_matches(".dex").parse::<u32>().unwrap_or(1));
133    let mut dex = rep.dex.clone();
134    if !javares.is_empty() {
135        let mut buf = std::io::Cursor::new(Vec::new());
136        {
137            let mut w = zip::ZipWriter::new(&mut buf);
138            let o = zip::write::SimpleFileOptions::default().last_modified_time(zip::DateTime::default());
139            for (n, d) in &javares {
140                w.start_file(n.as_str(), o).map_err(|e| e.to_string())?;
141                w.write_all(d).map_err(|e| e.to_string())?;
142            }
143            w.finish().map_err(|e| e.to_string())?;
144        }
145        write(&payload.join("dex/resources.jar"), &buf.into_inner())?;
146        dex.push("dex/resources.jar".into());
147        rep.java_resources = javares.len();
148    }
149    let entry = match (&a.entry_class, &a.entry_native) {
150        _ if a.transparent => Entry { transparent: true, dex_class: a.entry_class.clone(), ..Default::default() },
151        (Some(c), None) => Entry { dex_class: Some(c.clone()), ..Default::default() },
152        (None, Some(n)) => {
153            let (lib, sym) = n.split_once(':').ok_or("--entry-native wants LIB:SYMBOL")?;
154            Entry {
155                native_lib: Some(lib.trim_end_matches(".so").into()),
156                symbol: Some(sym.into()),
157                ..Default::default()
158            }
159        }
160        _ => return Err("give exactly one of --entry-class or --entry-native".into()),
161    };
162    let app = a.app.clone().unwrap_or_else(|| mi.package.clone());
163    let version =
164        a.version.clone().or_else(|| mi.version_name.clone()).ok_or("no --version and no versionName in the APK")?;
165    let own =
166        |c: &str| !c.starts_with("com.andysu.host.") && !c.starts_with("androidx.") && !c.starts_with("com.google.");
167    let desc = Descriptor {
168        schema: 1,
169        app: app.clone(),
170        version: version.clone(),
171        host_api_min: a.host_api,
172        entry,
173        dex,
174        native_dir: None,
175        needs: Needs {
176            permissions: mi.permissions.clone(),
177            activities: mi.activities.iter().filter(|c| own(c)).count() as u32,
178            services: mi.services.iter().filter(|c| own(c)).count() as u32,
179            receivers: mi.receivers.iter().filter(|c| own(c)).count() as u32,
180            providers: mi.providers.iter().filter(|c| own(c)).count() as u32,
181            features: mi.features.clone(),
182        },
183        native: BTreeMap::new(),
184    };
185    desc.validate().map_err(|e| e.to_string())?;
186    let mut dj = serde_json::to_vec_pretty(&desc).unwrap();
187    dj.push(b'\n');
188    write(&payload.join("andysu-payload.json"), &dj)?;
189    rep.app = app;
190    rep.version = version;
191    rep.manifest = mi;
192    if let (Some(tpl), Some(cfg)) = (&a.host_template, &a.config) {
193        // The payload as it is now becomes the one built into the host (a packed slot).
194        let site = a.out.join("factory-site");
195        let slot = a.out.join("factory");
196        let _ = std::fs::remove_dir_all(&site);
197        let o = crate::pack::Options {
198            payload: payload.clone(),
199            site,
200            channel: "stable".into(),
201            id: None,
202            host: None,
203            hot: vec![],
204            archive: vec![],
205            deltas: 0,
206            delta_format: andysu_core::manifest::PatchFormat::Zstd,
207            base_url: None,
208            require_16k: false,
209            allow_shell: false,
210            emit_slot: Some(slot.clone()),
211            no_rename: a.transparent,
212        };
213        crate::pack::pack(&o)?;
214        let ho = crate::hostapk::HostOptions {
215            template: tpl.clone(),
216            config: cfg.clone(),
217            factory: slot,
218            out: a.out.join("host.apk"),
219            keystore: a.keystore.clone(),
220            ks_pass: a.ks_pass.clone(),
221            key_alias: a.key_alias.clone(),
222            cleartext: a.cleartext,
223            transparent: a.transparent,
224            payload_has_assets: rep.assets > 0,
225        };
226        rep.host = Some(crate::hostapk::build(&a.apk, &ho)?);
227    }
228    Ok(rep)
229}