5.3 KiB · text History 30a56d2
//! Writing icons out as Android mipmap resources.

use std::io::Write;
use std::path::{Path, PathBuf};

use crate::render;
use crate::spec::IconSpec;

/// Adaptive icon layers are 108dp square.
pub const ADAPTIVE_DP: u32 = 108;
/// The flat launcher icon is 48dp — a different size, not a different
/// rendering of the same one. Writing it at the adaptive size makes it 4.5x
/// larger than every other app's.
pub const LEGACY_DP: u32 = 48;

/// The flat icon's pixel size for a given adaptive size. Exact at every
/// bucket: 108->48, 162->72, 216->96, 324->144, 432->192.
pub fn legacy_size(adaptive: u32) -> u32 {
    adaptive * LEGACY_DP / ADAPTIVE_DP
}

/// Android adaptive icons are 108dp; these are the density buckets and the
/// pixel size each works out to.
pub const DENSITIES: &[(&str, u32)] = &[
    ("mdpi", 108),
    ("hdpi", 162),
    ("xhdpi", 216),
    ("xxhdpi", 324),
    ("xxxhdpi", 432),
];

/// The `mipmap-anydpi-v26/<name>.xml` that binds the two layers together.
///
/// Without this Android has no idea the background and foreground PNGs are
/// related: it falls back to the flat `<name>.png` and the adaptive layers are
/// dead weight. Exporting the layers without it is the difference between an
/// icon that masks and parallaxes on a modern launcher and one that does not.
pub fn adaptive_icon_xml(base_name: &str) -> String {
    format!(
        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
         <adaptive-icon\n\
        \x20 xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\
        \x20   <background android:drawable=\"@mipmap/{base_name}_background\" />\n\
        \x20   <foreground android:drawable=\"@mipmap/{base_name}_foreground\" />\n\
         </adaptive-icon>\n"
    )
}

fn write_png(pm: &tiny_skia::Pixmap, path: &Path) -> Result<(), String> {
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
    }
    pm.save_png(path)
        .map_err(|e| format!("{}: {e}", path.display()))
}

/// Writes `ic_icon`, `ic_icon_background` and `ic_icon_foreground` into
/// `res/mipmap-*` under `root`, matching the layout the cbz apps ship.
pub fn export_mipmaps(
    spec: &IconSpec,
    root: &Path,
    base_name: &str,
) -> Result<Vec<PathBuf>, String> {
    let mut written = Vec::new();
    for (bucket, size) in DENSITIES {
        let dir = root.join(format!("mipmap-{bucket}"));
        for (suffix, pm) in [
            ("", render::render_legacy(spec, legacy_size(*size))),
            ("_background", render::render_background(spec, *size)),
            ("_foreground", render::render_foreground(spec, *size)),
        ] {
            let p = dir.join(format!("{base_name}{suffix}.png"));
            write_png(&pm, &p)?;
            written.push(p);
        }
    }

    let xml = root
        .join("mipmap-anydpi-v26")
        .join(format!("{base_name}.xml"));
    if let Some(dir) = xml.parent() {
        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
    }
    std::fs::write(&xml, adaptive_icon_xml(base_name))
        .map_err(|e| format!("{}: {e}", xml.display()))?;
    written.push(xml);

    Ok(written)
}

/// The mipmap tree as a single `.zip`, for handing to someone who is not
/// pointing at the project on disk.
///
/// The source document goes in beside the images. A bundle that contains only
/// PNGs is a dead end — whoever receives it cannot change the icon without
/// rebuilding the configuration by eye, which is the exact problem this whole
/// program exists to avoid.
pub fn export_zip(
    spec: &IconSpec,
    path: &Path,
    base_name: &str,
    document: Option<(&str, &str)>,
) -> Result<Vec<String>, String> {
    let file = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;
    let mut zip = zip::ZipWriter::new(file);
    let options = zip::write::SimpleFileOptions::default()
        .compression_method(zip::CompressionMethod::Deflated);

    let mut names = Vec::new();

    for (bucket, size) in DENSITIES {
        for (suffix, pm) in [
            ("", render::render_legacy(spec, legacy_size(*size))),
            ("_background", render::render_background(spec, *size)),
            ("_foreground", render::render_foreground(spec, *size)),
        ] {
            let name = format!("mipmap-{bucket}/{base_name}{suffix}.png");
            let png = pm
                .encode_png()
                .map_err(|e| format!("encoding {name}: {e}"))?;
            zip.start_file(&name, options)
                .map_err(|e| format!("{name}: {e}"))?;
            zip.write_all(&png).map_err(|e| format!("{name}: {e}"))?;
            names.push(name);
        }
    }

    let xml_name = format!("mipmap-anydpi-v26/{base_name}.xml");
    zip.start_file(&xml_name, options)
        .map_err(|e| format!("{xml_name}: {e}"))?;
    zip.write_all(adaptive_icon_xml(base_name).as_bytes())
        .map_err(|e| format!("{xml_name}: {e}"))?;
    names.push(xml_name);

    if let Some((doc_name, body)) = document {
        zip.start_file(doc_name, options)
            .map_err(|e| format!("{doc_name}: {e}"))?;
        zip.write_all(body.as_bytes())
            .map_err(|e| format!("{doc_name}: {e}"))?;
        names.push(doc_name.to_string());
    }

    zip.finish()
        .map_err(|e| format!("{}: {e}", path.display()))?;
    Ok(names)
}