Write the adaptive-icon descriptor and patch the manifest

7cbb63b · gmorell · 2026-09-22 04:35

11 files +435 -9
Message
{commit_body(@commit)}

Files changed

modified README.md
+18 −0
@@ -173,6 +173,7 @@ and leaves the app with its old icon. So:
173 173 | You pick | It writes to |
174 174 |---|---|
175 175 | a project root | `app/src/main/res` |
176 +| a repo whose client is under `android/` | `android/app/src/main/res` |
176 177 | a module root | `src/main/res` |
177 178 | a `res/` directory | itself |
178 179 | a `mipmap-*` bucket | the `res/` above it |
@@ -184,6 +185,23 @@ replaced. Destinations you have used before are offered in a dropdown and
184 185 remembered between sessions in `~/.config/icon-studio/config.toml` — that is a
185 186 property of the machine, not of the icon, so it never travels in a document.
186 187
188 +An export writes three things, because images alone are not enough:
189 +
190 +1. the PNGs, across every density;
191 +2. `mipmap-anydpi-v26/<name>.xml`, the adaptive-icon descriptor. Without it
192 + Android has no idea the background and foreground are related, falls back to
193 + the flat PNG, and the layers are dead weight;
194 +3. optionally, `android:icon` and `android:roundIcon` on `<application>` in
195 + `AndroidManifest.xml`. A manifest that names no icon shows the platform
196 + default while the build succeeds and nothing reports a problem.
197 +
198 +The manifest is patched as a targeted text edit, not by parsing and
199 +re-serialising: a manifest is hand-maintained and carries comments, attribute
200 +ordering and indentation that a round-trip through a generic XML writer would
201 +quietly destroy. Inserted attributes match the surrounding indentation, running
202 +it twice changes nothing the second time, and a manifest that already says the
203 +right thing is not rewritten at all.
204 +
187 205 **As a zip bundle.** One archive holding the same mipmap tree plus the source
188 206 `.toml`. A bundle of bare PNGs is a dead end — whoever receives it cannot
189 207 change the icon without rebuilding the configuration by eye, which is the
modified src/app.rs
+40 −6
@@ -10,6 +10,7 @@ use crate::config::Config;
10 10 use crate::doc::{self, Document, Session};
11 11 use crate::export;
12 12 use crate::icons::{self, IconEntry};
13 +use crate::manifest;
13 14 use crate::palette::{self, NamedColor, Theme};
14 15 use crate::render;
15 16 use crate::spec::{Background, Foreground, IconSpec};
@@ -96,6 +97,8 @@ pub struct Studio {
96 97 export_name: String,
97 98 /// Folder of mipmap directories, or a single zip.
98 99 export_zip: bool,
100 + /// Point AndroidManifest.xml at the icon as part of exporting.
101 + patch_manifest: bool,
99 102 config: Config,
100 103 status: String,
101 104
@@ -153,6 +156,7 @@ impl Studio {
153 156 export_dir: String::new(),
154 157 export_name: "ic_icon".into(),
155 158 export_zip: false,
159 + patch_manifest: true,
156 160 config: Config::load(),
157 161 status: String::new(),
158 162 path: None,
@@ -648,8 +652,22 @@ impl Studio {
648 652 .color(ui.visuals().warn_fg_color),
649 653 );
650 654 }
655 +
656 + // Only offered where there is a manifest to patch; on a plain
657 + // folder the checkbox would be a lie.
658 + let manifest_path = manifest::manifest_for(&resolved.root);
659 + if let Some(mp) = &manifest_path {
660 + ui.checkbox(&mut self.patch_manifest, "Point the manifest at it");
661 + ui.small(shorten(&mp.display().to_string()));
662 + } else if resolved.kind != Kind::Plain {
663 + ui.small(
664 + RichText::new("No AndroidManifest.xml beside this res directory.")
665 + .color(ui.visuals().warn_fg_color),
666 + );
667 + }
668 +
651 669 if ui.button("Export").clicked() {
652 − self.do_export(resolved.root.clone());
670 + self.do_export(resolved.root.clone(), manifest_path);
653 671 }
654 672 }
655 673 Err(e) => {
@@ -691,15 +709,31 @@ impl Studio {
691 709 self.status.clear();
692 710 }
693 711
694 − fn do_export(&mut self, root: std::path::PathBuf) {
712 + fn do_export(&mut self, root: std::path::PathBuf, manifest_path: Option<std::path::PathBuf>) {
695 713 match export::export_mipmaps(&self.spec, &root, &self.export_name) {
696 714 Ok(files) => {
697 − self.status = format!(
698 − "Wrote {} files across {} densities into {}.",
715 + let mut note = format!(
716 + "Wrote {} files into {}.",
699 717 files.len(),
700 − export::DENSITIES.len(),
701 718 shorten(&root.display().to_string()),
702 − )
719 + );
720 + // Reported separately from the write: the images landing
721 + // correctly while the manifest still points elsewhere is
722 + // precisely the failure that looks like success.
723 + if self.patch_manifest {
724 + if let Some(mp) = manifest_path {
725 + match manifest::patch_file(&mp, &self.export_name, true) {
726 + Ok(change) => {
727 + note.push(' ');
728 + note.push_str(&change.describe(&self.export_name));
729 + }
730 + Err(e) => {
731 + note.push_str(&format!(" Manifest not patched: {e}"));
732 + }
733 + }
734 + }
735 + }
736 + self.status = note;
703 737 }
704 738 Err(e) => self.status = format!("Export failed: {e}"),
705 739 }
modified src/export.rs
+35 −0
@@ -16,6 +16,23 @@ pub const DENSITIES: &[(&str, u32)] = &[
16 16 ("xxxhdpi", 432),
17 17 ];
18 18
19 +/// The `mipmap-anydpi-v26/<name>.xml` that binds the two layers together.
20 +///
21 +/// Without this Android has no idea the background and foreground PNGs are
22 +/// related: it falls back to the flat `<name>.png` and the adaptive layers are
23 +/// dead weight. Exporting the layers without it is the difference between an
24 +/// icon that masks and parallaxes on a modern launcher and one that does not.
25 +pub fn adaptive_icon_xml(base_name: &str) -> String {
26 + format!(
27 + "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
28 + <adaptive-icon\n\
29 + \x20 xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\
30 + \x20 <background android:drawable=\"@mipmap/{base_name}_background\" />\n\
31 + \x20 <foreground android:drawable=\"@mipmap/{base_name}_foreground\" />\n\
32 + </adaptive-icon>\n"
33 + )
34 +}
35 +
19 36 fn write_png(pm: &tiny_skia::Pixmap, path: &Path) -> Result<(), String> {
20 37 if let Some(dir) = path.parent() {
21 38 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
@@ -44,6 +61,17 @@ pub fn export_mipmaps(
44 61 written.push(p);
45 62 }
46 63 }
64 +
65 + let xml = root
66 + .join("mipmap-anydpi-v26")
67 + .join(format!("{base_name}.xml"));
68 + if let Some(dir) = xml.parent() {
69 + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
70 + }
71 + std::fs::write(&xml, adaptive_icon_xml(base_name))
72 + .map_err(|e| format!("{}: {e}", xml.display()))?;
73 + written.push(xml);
74 +
47 75 Ok(written)
48 76 }
49 77
@@ -84,6 +112,13 @@ pub fn export_zip(
84 112 }
85 113 }
86 114
115 + let xml_name = format!("mipmap-anydpi-v26/{base_name}.xml");
116 + zip.start_file(&xml_name, options)
117 + .map_err(|e| format!("{xml_name}: {e}"))?;
118 + zip.write_all(adaptive_icon_xml(base_name).as_bytes())
119 + .map_err(|e| format!("{xml_name}: {e}"))?;
120 + names.push(xml_name);
121 +
87 122 if let Some((doc_name, body)) = document {
88 123 zip.start_file(doc_name, options)
89 124 .map_err(|e| format!("{doc_name}: {e}"))?;
modified src/lib.rs
+1 −0
@@ -12,6 +12,7 @@ pub mod config;
12 12 pub mod doc;
13 13 pub mod export;
14 14 pub mod icons;
15 +pub mod manifest;
15 16 pub mod palette;
16 17 pub mod render;
17 18 pub mod spec;
added src/manifest.rs
+177 −0
@@ -0,0 +1,177 @@
1 +//! Pointing `AndroidManifest.xml` at the icon that was just exported.
2 +//!
3 +//! Exporting the images is only two thirds of the job. Without
4 +//! `android:icon` on `<application>` the launcher shows the platform default
5 +//! and nothing anywhere reports a problem — the files are on disk, the build
6 +//! succeeds, and the icon simply never appears.
7 +//!
8 +//! Patching is done as a targeted text edit rather than by parsing and
9 +//! re-serialising the XML. A manifest is hand-maintained: it carries comments,
10 +//! attribute ordering and indentation that a round-trip through a generic XML
11 +//! writer would quietly destroy. The cost is that this only understands the
12 +//! shapes a manifest actually takes, which is the right trade for editing
13 +//! someone's source file.
14 +
15 +use std::path::{Path, PathBuf};
16 +
17 +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18 +pub enum Change {
19 + /// Already pointed at this icon; nothing written.
20 + AlreadyCorrect,
21 + /// `android:icon` was absent and has been added.
22 + Added,
23 + /// It pointed somewhere else and now points here.
24 + Updated,
25 +}
26 +
27 +impl Change {
28 + pub fn describe(self, base_name: &str) -> String {
29 + match self {
30 + Change::AlreadyCorrect => {
31 + format!("Manifest already points at @mipmap/{base_name}.")
32 + }
33 + Change::Added => format!("Added android:icon=\"@mipmap/{base_name}\" to the manifest."),
34 + Change::Updated => format!("Repointed the manifest at @mipmap/{base_name}."),
35 + }
36 + }
37 +}
38 +
39 +/// The manifest belonging to a `res` directory: `src/main/res` sits beside
40 +/// `src/main/AndroidManifest.xml`.
41 +pub fn manifest_for(res_root: &Path) -> Option<PathBuf> {
42 + let candidate = res_root.parent()?.join("AndroidManifest.xml");
43 + candidate.is_file().then_some(candidate)
44 +}
45 +
46 +/// Finds the span of the opening `<application ...>` tag.
47 +fn application_tag(xml: &str) -> Result<(usize, usize), String> {
48 + let start = xml
49 + .find("<application")
50 + .ok_or_else(|| "no <application> element".to_string())?;
51 + // Attribute values can contain '>', so step over quoted spans rather than
52 + // taking the first one.
53 + let bytes = xml.as_bytes();
54 + let mut i = start;
55 + let mut quote: Option<u8> = None;
56 + while i < bytes.len() {
57 + let c = bytes[i];
58 + match quote {
59 + Some(q) if c == q => quote = None,
60 + Some(_) => {}
61 + None if c == b'"' || c == b'\'' => quote = Some(c),
62 + None if c == b'>' => return Ok((start, i)),
63 + None => {}
64 + }
65 + i += 1;
66 + }
67 + Err("unterminated <application> tag".into())
68 +}
69 +
70 +/// Reads the value of `attr` within `span`, if present.
71 +fn attribute<'a>(
72 + xml: &'a str,
73 + span: (usize, usize),
74 + attr: &str,
75 +) -> Option<(usize, usize, &'a str)> {
76 + let tag = &xml[span.0..span.1];
77 + let needle = format!("{attr}=");
78 + let mut from = 0;
79 + while let Some(rel) = tag[from..].find(&needle) {
80 + let at = from + rel;
81 + // Must be preceded by whitespace, or `android:icon` would match
82 + // inside `android:roundIcon`.
83 + let preceded_ok = at == 0
84 + || tag[..at]
85 + .chars()
86 + .next_back()
87 + .is_some_and(char::is_whitespace);
88 + let after = at + needle.len();
89 + let quote = tag.as_bytes().get(after).copied();
90 + if preceded_ok && (quote == Some(b'"') || quote == Some(b'\'')) {
91 + let q = quote.unwrap() as char;
92 + if let Some(end_rel) = tag[after + 1..].find(q) {
93 + let value_start = after + 1;
94 + let value_end = after + 1 + end_rel;
95 + return Some((
96 + span.0 + at,
97 + span.0 + value_end + 1,
98 + &tag[value_start..value_end],
99 + ));
100 + }
101 + }
102 + from = at + needle.len();
103 + }
104 + None
105 +}
106 +
107 +/// The indentation used by the attributes already on the tag, so an inserted
108 +/// one lines up with them instead of sitting at a lonely column.
109 +fn attribute_indent(xml: &str, span: (usize, usize)) -> String {
110 + let tag = &xml[span.0..span.1];
111 + for line in tag.lines().skip(1) {
112 + let trimmed = line.trim_start();
113 + if trimmed.starts_with("android:") || trimmed.starts_with("tools:") {
114 + // Leading newline included: the attribute goes on its own line,
115 + // aligned with the ones already there.
116 + return format!("\n{}", &line[..line.len() - trimmed.len()]);
117 + }
118 + }
119 + // Single-line <application ...>: match the tag's own indentation plus a
120 + // level, which is what a manifest laid out that way tends to use.
121 + let line_start = xml[..span.0].rfind('\n').map(|i| i + 1).unwrap_or(0);
122 + let base = &xml[line_start..span.0];
123 + format!("\n{base} ")
124 +}
125 +
126 +/// Points the manifest at `@mipmap/<base_name>`, returning the new text.
127 +///
128 +/// `round` also sets `android:roundIcon`, which is what the cbz apps do.
129 +pub fn patch_source(xml: &str, base_name: &str, round: bool) -> Result<(String, Change), String> {
130 + let want = format!("@mipmap/{base_name}");
131 + let mut out = xml.to_string();
132 + let mut change = Change::AlreadyCorrect;
133 +
134 + // roundIcon first: editing icon first would shift the offsets of a
135 + // roundIcon that appears after it.
136 + let attrs: &[&str] = if round {
137 + &["android:roundIcon", "android:icon"]
138 + } else {
139 + &["android:icon"]
140 + };
141 +
142 + for attr in attrs {
143 + let span = application_tag(&out)?;
144 + match attribute(&out, span, attr) {
145 + Some((_, _, value)) if value == want => {}
146 + Some((start, end, _)) => {
147 + out.replace_range(start..end, &format!("{attr}=\"{want}\""));
148 + if change != Change::Added {
149 + change = Change::Updated;
150 + }
151 + }
152 + None => {
153 + let indent = attribute_indent(&out, span);
154 + let insert_at = span.0 + "<application".len();
155 + out.insert_str(insert_at, &format!("{indent}{attr}=\"{want}\""));
156 + change = Change::Added;
157 + }
158 + }
159 + }
160 +
161 + Ok((out, change))
162 +}
163 +
164 +/// Patches the manifest belonging to `res_root` in place.
165 +///
166 +/// Writes nothing when the manifest already says the right thing, so running
167 +/// an export twice does not keep touching the file's mtime and dirtying a
168 +/// working tree.
169 +pub fn patch_file(path: &Path, base_name: &str, round: bool) -> Result<Change, String> {
170 + let before = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
171 + let (after, change) =
172 + patch_source(&before, base_name, round).map_err(|e| format!("{}: {e}", path.display()))?;
173 + if change != Change::AlreadyCorrect {
174 + std::fs::write(path, after).map_err(|e| format!("{}: {e}", path.display()))?;
175 + }
176 + Ok(change)
177 +}
modified src/target.rs
+13 −0
@@ -64,6 +64,11 @@ fn is_definitely_res(p: &Path) -> bool {
64 64 const RES_CANDIDATES: &[&str] = &[
65 65 "app/src/main/res",
66 66 "src/main/res",
67 + // A repository whose Android client is a subdirectory rather than the
68 + // whole project — Diogramos keeps a Phoenix app at the root and the
69 + // client under android/.
70 + "android/app/src/main/res",
71 + "android/src/main/res",
67 72 "app/src/debug/res",
68 73 "res",
69 74 ];
@@ -139,6 +144,14 @@ pub fn plan(root: &Path, base_name: &str) -> Vec<PlannedFile> {
139 144 out.push(PlannedFile { path, overwrites });
140 145 }
141 146 }
147 + let xml = root
148 + .join("mipmap-anydpi-v26")
149 + .join(format!("{base_name}.xml"));
150 + let overwrites = xml.exists();
151 + out.push(PlannedFile {
152 + path: xml,
153 + overwrites,
154 + });
142 155 out
143 156 }
144 157
added tests/manifests.rs
+135 −0
@@ -0,0 +1,135 @@
1 +//! Patching AndroidManifest.xml to point at the exported icon.
2 +
3 +use icon_studio::manifest::{self, Change};
4 +
5 +const DIOGRAMOS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
6 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
7 +
8 + <uses-permission android:name="android.permission.INTERNET"/>
9 +
10 + <application
11 + android:label="@string/app_name"
12 + android:allowBackup="false"
13 + android:theme="@style/Theme.Diogramos"
14 + android:usesCleartextTraffic="true">
15 +
16 + <activity android:name=".MainActivity" android:exported="true"/>
17 + </application>
18 +</manifest>
19 +"#;
20 +
21 +#[test]
22 +fn adds_the_icon_when_the_manifest_has_none() {
23 + // The case that started this: files exported correctly, no icon shown,
24 + // because nothing referenced them.
25 + let (out, change) = manifest::patch_source(DIOGRAMOS, "ic_icon", true).unwrap();
26 + assert_eq!(change, Change::Added);
27 + assert!(out.contains(r#"android:icon="@mipmap/ic_icon""#), "{out}");
28 + assert!(
29 + out.contains(r#"android:roundIcon="@mipmap/ic_icon""#),
30 + "{out}"
31 + );
32 +
33 + // Everything else survives.
34 + assert!(out.contains(r#"android:theme="@style/Theme.Diogramos""#));
35 + assert!(out.contains("android.permission.INTERNET"));
36 + assert!(out.contains(".MainActivity"));
37 +}
38 +
39 +#[test]
40 +fn inserted_attributes_match_the_surrounding_indentation() {
41 + let (out, _) = manifest::patch_source(DIOGRAMOS, "ic_icon", true).unwrap();
42 + let line = out
43 + .lines()
44 + .find(|l| l.contains("android:icon="))
45 + .expect("icon line");
46 + assert!(
47 + line.starts_with(" android:icon="),
48 + "should line up with the other attributes, got {line:?}"
49 + );
50 +}
51 +
52 +#[test]
53 +fn patching_twice_changes_nothing_the_second_time() {
54 + let (once, first) = manifest::patch_source(DIOGRAMOS, "ic_icon", true).unwrap();
55 + assert_eq!(first, Change::Added);
56 + let (twice, second) = manifest::patch_source(&once, "ic_icon", true).unwrap();
57 + assert_eq!(second, Change::AlreadyCorrect);
58 + assert_eq!(once, twice, "idempotent");
59 +}
60 +
61 +#[test]
62 +fn repoints_an_icon_that_names_something_else() {
63 + let src = DIOGRAMOS.replace(
64 + r#" android:label="@string/app_name""#,
65 + " android:label=\"@string/app_name\"\n android:icon=\"@mipmap/ic_launcher\"",
66 + );
67 + let (out, change) = manifest::patch_source(&src, "ic_icon", false).unwrap();
68 + assert_eq!(change, Change::Updated);
69 + assert!(out.contains(r#"android:icon="@mipmap/ic_icon""#));
70 + assert!(!out.contains("ic_launcher"));
71 +}
72 +
73 +#[test]
74 +fn icon_and_round_icon_are_told_apart() {
75 + // `android:icon` is a substring of `android:roundIcon`; a naive search
76 + // would rewrite the wrong one.
77 + let src = DIOGRAMOS.replace(
78 + r#" android:label="@string/app_name""#,
79 + " android:label=\"@string/app_name\"\n android:roundIcon=\"@mipmap/old_round\"",
80 + );
81 + let (out, _) = manifest::patch_source(&src, "ic_icon", true).unwrap();
82 + assert!(
83 + out.contains(r#"android:roundIcon="@mipmap/ic_icon""#),
84 + "{out}"
85 + );
86 + assert!(out.contains(r#"android:icon="@mipmap/ic_icon""#), "{out}");
87 + assert!(!out.contains("old_round"));
88 + assert_eq!(out.matches("android:roundIcon=").count(), 1);
89 + assert_eq!(out.matches(r#"android:icon="#).count(), 1);
90 +}
91 +
92 +#[test]
93 +fn a_greater_than_inside_an_attribute_does_not_end_the_tag() {
94 + let src = r#"<manifest xmlns:android="http://schemas.android.com/apk/res/android">
95 + <application android:label="a > b" android:theme="@style/T">
96 + </application>
97 +</manifest>
98 +"#;
99 + let (out, change) = manifest::patch_source(src, "ic_icon", false).unwrap();
100 + assert_eq!(change, Change::Added);
101 + assert!(out.contains(r#"android:label="a > b""#), "{out}");
102 + assert!(out.contains(r#"android:icon="@mipmap/ic_icon""#), "{out}");
103 +}
104 +
105 +#[test]
106 +fn a_single_line_application_tag_is_handled() {
107 + let src = r#"<manifest xmlns:android="http://schemas.android.com/apk/res/android">
108 + <application android:label="@string/app_name">
109 + </application>
110 +</manifest>
111 +"#;
112 + let (out, change) = manifest::patch_source(src, "ic_icon", false).unwrap();
113 + assert_eq!(change, Change::Added);
114 + assert!(out.contains(r#"android:icon="@mipmap/ic_icon""#), "{out}");
115 +}
116 +
117 +#[test]
118 +fn a_manifest_without_an_application_element_is_an_error_not_a_guess() {
119 + let src = r#"<manifest xmlns:android="http://schemas.android.com/apk/res/android">
120 +</manifest>
121 +"#;
122 + assert!(manifest::patch_source(src, "ic_icon", false).is_err());
123 +}
124 +
125 +#[test]
126 +fn the_real_cbzauth_manifest_is_already_correct() {
127 + // Ground truth: a manifest that works. Patching it must be a no-op.
128 + let p = "/home/gmorell/Development/projects/cbz_clients_decompilation/recreated/cbzauth/app/src/main/AndroidManifest.xml";
129 + let Ok(src) = std::fs::read_to_string(p) else {
130 + return; // not checked out; nothing to assert
131 + };
132 + let (out, change) = manifest::patch_source(&src, "ic_icon", true).unwrap();
133 + assert_eq!(change, Change::AlreadyCorrect);
134 + assert_eq!(out, src, "a correct manifest must not be rewritten");
135 +}
modified tests/reproduce.rs
+9 −1
@@ -153,7 +153,9 @@ fn export_writes_every_density_and_layer() {
153 153 let files = icon_studio::export::export_mipmaps(&casabeza_split(), &dir, "ic_icon")
154 154 .expect("export succeeds");
155 155
156 − assert_eq!(files.len(), icon_studio::export::DENSITIES.len() * 3);
156 + // Three PNGs per density, plus the one adaptive-icon descriptor that
157 + // binds the background and foreground layers together.
158 + assert_eq!(files.len(), icon_studio::export::DENSITIES.len() * 3 + 1);
157 159 for (bucket, size) in icon_studio::export::DENSITIES {
158 160 for suffix in ["", "_background", "_foreground"] {
159 161 let p = dir
@@ -166,5 +168,11 @@ fn export_writes_every_density_and_layer() {
166 168 }
167 169 // xxxhdpi must land on 432px, the size the shipped cbz icons use.
168 170 assert!(dir.join("mipmap-xxxhdpi/ic_icon.png").exists());
171 +
172 + // Without this Android ignores the layers and falls back to the flat PNG.
173 + let xml = dir.join("mipmap-anydpi-v26/ic_icon.xml");
174 + let body = std::fs::read_to_string(&xml).expect("adaptive icon descriptor");
175 + assert!(body.contains(r#"@mipmap/ic_icon_background"#), "{body}");
176 + assert!(body.contains(r#"@mipmap/ic_icon_foreground"#), "{body}");
169 177 std::fs::remove_dir_all(&dir).ok();
170 178 }
modified tests/targets.rs
+7 −2
@@ -123,7 +123,7 @@ fn the_plan_lists_every_file_and_flags_replacements() {
123 123 let t = Tmp::new("plan");
124 124
125 125 let fresh = target::plan(t.path(), "ic_icon");
126 − assert_eq!(fresh.len(), icon_studio::export::DENSITIES.len() * 3);
126 + assert_eq!(fresh.len(), icon_studio::export::DENSITIES.len() * 3 + 1);
127 127 assert!(fresh.iter().all(|f| !f.overwrites));
128 128 assert!(target::summarise(&fresh).contains("none existing"));
129 129
@@ -175,7 +175,8 @@ fn the_zip_bundle_carries_the_images_and_the_document() {
175 175 )
176 176 .expect("bundle written");
177 177
178 − assert_eq!(names.len(), icon_studio::export::DENSITIES.len() * 3 + 1);
178 + // Images, the adaptive-icon descriptor, and the document.
179 + assert_eq!(names.len(), icon_studio::export::DENSITIES.len() * 3 + 2);
179 180
180 181 let f = fs::File::open(&out).unwrap();
181 182 let mut zip = zip::ZipArchive::new(f).expect("valid zip");
@@ -185,6 +186,10 @@ fn the_zip_bundle_carries_the_images_and_the_document() {
185 186
186 187 assert!(entries.contains(&"mipmap-xxxhdpi/ic_icon.png".to_string()));
187 188 assert!(entries.contains(&"mipmap-mdpi/ic_icon_foreground.png".to_string()));
189 + assert!(
190 + entries.contains(&"mipmap-anydpi-v26/ic_icon.xml".to_string()),
191 + "a bundle of layers without the descriptor binding them is not usable"
192 + );
188 193 assert!(
189 194 entries.contains(&"ic_icon.toml".to_string()),
190 195 "the bundle should be reproducible, not just a pile of PNGs"

Parents: bf97ff0