neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Write the adaptive-icon descriptor and patch the manifest
7cbb63b · gmorell · 2026-09-22 04:35
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