//! Pointing `AndroidManifest.xml` at the icon that was just exported.
//!
//! Exporting the images is only two thirds of the job. Without
//! `android:icon` on `<application>` the launcher shows the platform default
//! and nothing anywhere reports a problem — the files are on disk, the build
//! succeeds, and the icon simply never appears.
//!
//! Patching is done as a targeted text edit rather than by parsing and
//! re-serialising the XML. A manifest is hand-maintained: it carries comments,
//! attribute ordering and indentation that a round-trip through a generic XML
//! writer would quietly destroy. The cost is that this only understands the
//! shapes a manifest actually takes, which is the right trade for editing
//! someone's source file.
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Change {
/// Already pointed at this icon; nothing written.
AlreadyCorrect,
/// `android:icon` was absent and has been added.
Added,
/// It pointed somewhere else and now points here.
Updated,
}
impl Change {
pub fn describe(self, base_name: &str) -> String {
match self {
Change::AlreadyCorrect => {
format!("Manifest already points at @mipmap/{base_name}.")
}
Change::Added => format!("Added android:icon=\"@mipmap/{base_name}\" to the manifest."),
Change::Updated => format!("Repointed the manifest at @mipmap/{base_name}."),
}
}
}
/// The manifest belonging to a `res` directory: `src/main/res` sits beside
/// `src/main/AndroidManifest.xml`.
pub fn manifest_for(res_root: &Path) -> Option<PathBuf> {
let candidate = res_root.parent()?.join("AndroidManifest.xml");
candidate.is_file().then_some(candidate)
}
/// Finds the span of the opening `<application ...>` tag.
fn application_tag(xml: &str) -> Result<(usize, usize), String> {
let start = xml
.find("<application")
.ok_or_else(|| "no <application> element".to_string())?;
// Attribute values can contain '>', so step over quoted spans rather than
// taking the first one.
let bytes = xml.as_bytes();
let mut i = start;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) if c == q => quote = None,
Some(_) => {}
None if c == b'"' || c == b'\'' => quote = Some(c),
None if c == b'>' => return Ok((start, i)),
None => {}
}
i += 1;
}
Err("unterminated <application> tag".into())
}
/// Reads the value of `attr` within `span`, if present.
fn attribute<'a>(
xml: &'a str,
span: (usize, usize),
attr: &str,
) -> Option<(usize, usize, &'a str)> {
let tag = &xml[span.0..span.1];
let needle = format!("{attr}=");
let mut from = 0;
while let Some(rel) = tag[from..].find(&needle) {
let at = from + rel;
// Must be preceded by whitespace, or `android:icon` would match
// inside `android:roundIcon`.
let preceded_ok = at == 0
|| tag[..at]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
let after = at + needle.len();
let quote = tag.as_bytes().get(after).copied();
if preceded_ok && (quote == Some(b'"') || quote == Some(b'\'')) {
let q = quote.unwrap() as char;
if let Some(end_rel) = tag[after + 1..].find(q) {
let value_start = after + 1;
let value_end = after + 1 + end_rel;
return Some((
span.0 + at,
span.0 + value_end + 1,
&tag[value_start..value_end],
));
}
}
from = at + needle.len();
}
None
}
/// The indentation used by the attributes already on the tag, so an inserted
/// one lines up with them instead of sitting at a lonely column.
fn attribute_indent(xml: &str, span: (usize, usize)) -> String {
let tag = &xml[span.0..span.1];
for line in tag.lines().skip(1) {
let trimmed = line.trim_start();
if trimmed.starts_with("android:") || trimmed.starts_with("tools:") {
// Leading newline included: the attribute goes on its own line,
// aligned with the ones already there.
return format!("\n{}", &line[..line.len() - trimmed.len()]);
}
}
// Single-line <application ...>: match the tag's own indentation plus a
// level, which is what a manifest laid out that way tends to use.
let line_start = xml[..span.0].rfind('\n').map(|i| i + 1).unwrap_or(0);
let base = &xml[line_start..span.0];
format!("\n{base} ")
}
/// Points the manifest at `@mipmap/<base_name>`, returning the new text.
///
/// `round` also sets `android:roundIcon`, which is what the cbz apps do.
pub fn patch_source(xml: &str, base_name: &str, round: bool) -> Result<(String, Change), String> {
let want = format!("@mipmap/{base_name}");
let mut out = xml.to_string();
let mut change = Change::AlreadyCorrect;
// roundIcon first: editing icon first would shift the offsets of a
// roundIcon that appears after it.
let attrs: &[&str] = if round {
&["android:roundIcon", "android:icon"]
} else {
&["android:icon"]
};
for attr in attrs {
let span = application_tag(&out)?;
match attribute(&out, span, attr) {
Some((_, _, value)) if value == want => {}
Some((start, end, _)) => {
out.replace_range(start..end, &format!("{attr}=\"{want}\""));
if change != Change::Added {
change = Change::Updated;
}
}
None => {
let indent = attribute_indent(&out, span);
let insert_at = span.0 + "<application".len();
out.insert_str(insert_at, &format!("{indent}{attr}=\"{want}\""));
change = Change::Added;
}
}
}
Ok((out, change))
}
/// Patches the manifest belonging to `res_root` in place.
///
/// Writes nothing when the manifest already says the right thing, so running
/// an export twice does not keep touching the file's mtime and dirtying a
/// working tree.
pub fn patch_file(path: &Path, base_name: &str, round: bool) -> Result<Change, String> {
let before = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let (after, change) =
patch_source(&before, base_name, round).map_err(|e| format!("{}: {e}", path.display()))?;
if change != Change::AlreadyCorrect {
std::fs::write(path, after).map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(change)
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
6.7 KiB · text
History
7cbb63b