6.0 KiB · text History bf97ff0
//! Working out where icons should actually be written.
//!
//! Export lays down `mipmap-<density>/` directories, which only mean anything
//! inside an Android `res/` directory. Picking a project root and getting
//! `mipmap-xxxhdpi/` dropped next to `build.gradle.kts` is a silent mistake —
//! the files are written, nothing complains, and the app keeps its old icon.
//! So the picked directory is interpreted rather than taken literally.

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

use crate::export::DENSITIES;

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
    /// The directory is itself an Android `res/`.
    AndroidRes,
    /// An Android module or project; a `res/` was found beneath it.
    AndroidProject,
    /// Somewhere else. Written to literally.
    Plain,
}

#[derive(Clone, PartialEq, Debug)]
pub struct Target {
    /// Where the `mipmap-*` directories will be created.
    pub root: PathBuf,
    pub kind: Kind,
    /// Why this directory, in a sentence, for the UI to show.
    pub note: String,
}

fn looks_like_res(p: &Path) -> bool {
    if !p.is_dir() {
        return false;
    }
    if p.file_name().is_some_and(|n| n == "res") {
        return true;
    }
    // A res directory by content rather than by name: resource folders are
    // the give-away, since a project root has none of these.
    let Ok(entries) = std::fs::read_dir(p) else {
        return false;
    };
    entries.filter_map(Result::ok).any(|e| {
        let n = e.file_name();
        let n = n.to_string_lossy();
        n.starts_with("mipmap-") || n.starts_with("drawable") || n == "values"
    })
}

/// Evidence that a directory is a resource root which does not amount to
/// "something inside it is named like a resource bucket".
fn is_definitely_res(p: &Path) -> bool {
    if !p.is_dir() {
        return false;
    }
    if p.file_name().is_some_and(|n| n == "res") {
        return true;
    }
    p.join("values").is_dir()
}

/// The conventional places a `res/` sits relative to a project or module root.
const RES_CANDIDATES: &[&str] = &[
    "app/src/main/res",
    "src/main/res",
    "app/src/debug/res",
    "res",
];

/// Interprets a directory the user picked into somewhere to actually write.
pub fn resolve(picked: &Path) -> Target {
    // Browsing into `mipmap-xxxhdpi` to see the existing icons and stopping
    // there is an easy thing to do, and writing `mipmap-*/` inside it would
    // nest a resource tree inside a resource directory.
    if let Some(name) = picked.file_name().map(|n| n.to_string_lossy().to_string()) {
        let is_resource_bucket = name.starts_with("mipmap-")
            || name.starts_with("drawable")
            || name.starts_with("values");
        if is_resource_bucket {
            if let Some(parent) = picked.parent() {
                // A stricter test than looks_like_res on purpose. That one
                // counts "contains a mipmap-* directory" as evidence, which
                // the parent of a bucket always satisfies — including a stray
                // mipmap-foo on a desktop, whose parent is the desktop.
                if is_definitely_res(parent) {
                    return Target {
                        root: parent.to_path_buf(),
                        kind: Kind::AndroidRes,
                        note: format!("Using the parent res directory, not {name}."),
                    };
                }
            }
        }
    }

    if looks_like_res(picked) {
        return Target {
            root: picked.to_path_buf(),
            kind: Kind::AndroidRes,
            note: "Android resource directory.".into(),
        };
    }

    for rel in RES_CANDIDATES {
        let candidate = picked.join(rel);
        if looks_like_res(&candidate) {
            return Target {
                root: candidate,
                kind: Kind::AndroidProject,
                note: format!("Found an Android res directory at {rel}."),
            };
        }
    }

    Target {
        root: picked.to_path_buf(),
        kind: Kind::Plain,
        note: "Not an Android project — writing mipmap directories here.".into(),
    }
}

#[derive(Clone, PartialEq, Debug)]
pub struct PlannedFile {
    pub path: PathBuf,
    /// True when a file is already there and will be replaced.
    pub overwrites: bool,
}

/// Exactly what an export would write, so it can be shown before it happens.
pub fn plan(root: &Path, base_name: &str) -> Vec<PlannedFile> {
    let mut out = Vec::new();
    for (bucket, _) in DENSITIES {
        for suffix in ["", "_background", "_foreground"] {
            let path = root
                .join(format!("mipmap-{bucket}"))
                .join(format!("{base_name}{suffix}.png"));
            let overwrites = path.exists();
            out.push(PlannedFile { path, overwrites });
        }
    }
    out
}

/// A short "12 files, 3 replaced" for the UI.
pub fn summarise(plan: &[PlannedFile]) -> String {
    let replaced = plan.iter().filter(|f| f.overwrites).count();
    match replaced {
        0 => format!("{} files, none existing", plan.len()),
        n => format!("{} files, {n} replaced", plan.len()),
    }
}

/// Whether the target is somewhere icons can be written.
///
/// Deliberately a cheap check — this runs while the panel is drawn, so it must
/// not touch the filesystem beyond a stat. The destination always comes from a
/// folder dialog or a recent entry that was filtered for existence, so
/// requiring it to exist costs nothing and makes a deleted folder visible
/// before the export rather than halfway through writing fifteen files. Only
/// the `mipmap-*` directories beneath it are created.
pub fn writable(root: &Path) -> Result<(), String> {
    if root.as_os_str().is_empty() {
        return Err("No folder chosen.".into());
    }
    let meta =
        std::fs::metadata(root).map_err(|_| format!("{} is not reachable.", root.display()))?;
    if !meta.is_dir() {
        return Err(format!("{} is not a directory.", root.display()));
    }
    if meta.permissions().readonly() {
        return Err(format!("{} is read-only.", root.display()));
    }
    Ok(())
}