6.7 KiB · text History 7cbb63b
//! 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)
}