Skip to main content

resopt/
android.rs

1//! Android resource semantics derived from a project-relative path.
2//!
3//! Files under `res/` are addressed by resource *name* (`R.drawable.name`,
4//! `@drawable/name`), never by filename, and the directory qualifiers select a
5//! configuration at runtime. Files under `assets/` and `res/raw` are opened by
6//! path or as raw streams, so their encoded format is part of the app contract.
7use serde::{Deserialize, Serialize};
8use std::path::{Component, Path, PathBuf};
9
10/// First API level that decodes lossy WebP.
11pub const WEBP_LOSSY_MIN_SDK: u32 = 14;
12/// First API level that decodes lossless WebP and WebP with transparency.
13pub const WEBP_LOSSLESS_ALPHA_MIN_SDK: u32 = 18;
14
15const RES_TYPES: [&str; 14] = [
16    "anim",
17    "animator",
18    "color",
19    "drawable",
20    "font",
21    "interpolator",
22    "layout",
23    "menu",
24    "mipmap",
25    "navigation",
26    "raw",
27    "transition",
28    "values",
29    "xml",
30];
31const DENSITIES: [&str; 8] = [
32    "ldpi", "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi", "nodpi", "anydpi",
33];
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct AndroidResource {
38    /// Module directory (the parent of `src/<set>` or of `res`), project-relative.
39    pub module: PathBuf,
40    /// Gradle source set such as `main` or `debug`; empty for a bare `res/`.
41    pub source_set: String,
42    /// `res` or `assets`.
43    pub area: String,
44    /// Resource type for `res` files, e.g. `drawable`, `mipmap`, `raw`.
45    pub res_type: Option<String>,
46    /// Directory qualifiers in declaration order, e.g. `["ldrtl", "xxhdpi"]`.
47    pub qualifiers: Vec<String>,
48    pub density: Option<String>,
49    pub rtl: bool,
50    /// Platform version qualifier (`-v26`).
51    pub api_level: Option<u32>,
52    /// Resource name: the basename without extension (and without `.9`).
53    pub name: Option<String>,
54    pub nine_patch: bool,
55}
56
57impl AndroidResource {
58    /// Stable description of how this file may be transformed; part of reuse keys.
59    pub(crate) fn policy_key(&self, min_sdk: Option<u32>) -> String {
60        format!(
61            "android:{}:{}:{}:{}",
62            self.area,
63            self.res_type.as_deref().unwrap_or("-"),
64            if self.nine_patch { "9" } else { "-" },
65            min_sdk.map_or("unknown".to_string(), |v| v.to_string())
66        )
67    }
68
69    /// Why this file must keep its encoded format, if it must.
70    pub(crate) fn format_lock(&self) -> Option<&'static str> {
71        if self.nine_patch {
72            Some("android_nine_patch")
73        } else if self.res_type.as_deref() == Some("mipmap") {
74            Some("android_launcher_icon")
75        } else if self.res_type.as_deref() == Some("raw") {
76            Some("android_raw_resource")
77        } else {
78            None
79        }
80    }
81}
82
83fn normal_components(path: &Path) -> Option<Vec<&str>> {
84    path.components()
85        .map(|part| match part {
86            Component::Normal(name) => name.to_str(),
87            _ => None,
88        })
89        .collect()
90}
91
92/// Classify a project-relative file path. Returns `None` for non-Android paths.
93pub fn classify(path: &Path) -> Option<AndroidResource> {
94    let parts = normal_components(path)?;
95    let file = *parts.last()?;
96    // `<module>/src/<set>/assets/**`
97    if let Some(index) = parts
98        .windows(3)
99        .position(|w| w[0] == "src" && w[2] == "assets")
100        .filter(|index| index + 3 < parts.len())
101    {
102        return Some(AndroidResource {
103            module: parts[..index].iter().collect(),
104            source_set: parts[index + 1].to_string(),
105            area: "assets".into(),
106            res_type: None,
107            qualifiers: vec![],
108            density: None,
109            rtl: false,
110            api_level: None,
111            name: None,
112            nine_patch: false,
113        });
114    }
115    // `**/res/<type>[-qualifiers]/<file>`: resource directories are never nested.
116    let count = parts.len();
117    if count < 3 || parts[count - 3] != "res" {
118        return None;
119    }
120    let mut segments = parts[count - 2].split('-');
121    let res_type = segments.next()?;
122    if !RES_TYPES.contains(&res_type) {
123        return None;
124    }
125    let qualifiers: Vec<String> = segments.map(str::to_string).collect();
126    let (module, source_set) = if count >= 5 && parts[count - 5] == "src" {
127        (
128            parts[..count - 5].iter().collect(),
129            parts[count - 4].to_string(),
130        )
131    } else {
132        (parts[..count - 3].iter().collect(), String::new())
133    };
134    let lower = file.to_ascii_lowercase();
135    let nine_patch = lower.ends_with(".9.png");
136    let stem = file.split('.').next().filter(|stem| !stem.is_empty());
137    Some(AndroidResource {
138        module,
139        source_set,
140        area: "res".into(),
141        res_type: Some(res_type.to_string()),
142        density: qualifiers
143            .iter()
144            .find(|q| DENSITIES.contains(&q.as_str()) || q.ends_with("dpi"))
145            .cloned(),
146        rtl: qualifiers.iter().any(|q| q == "ldrtl"),
147        api_level: qualifiers
148            .iter()
149            .find_map(|q| q.strip_prefix('v').and_then(|v| v.parse().ok())),
150        qualifiers,
151        name: stem.map(str::to_string),
152        nine_patch,
153    })
154}
155
156/// Whether a WebP candidate can be decoded on every API level the app supports.
157/// `Err` carries the precise reason shown to the user.
158pub(crate) fn webp_compatibility(
159    min_sdk: Option<u32>,
160    lossless: bool,
161    has_alpha: bool,
162) -> Result<(), String> {
163    let required = if lossless || has_alpha {
164        WEBP_LOSSLESS_ALPHA_MIN_SDK
165    } else {
166        WEBP_LOSSY_MIN_SDK
167    };
168    match min_sdk {
169        Some(level) if level >= required => Ok(()),
170        Some(level) => Err(format!(
171            "android_min_sdk_{level}_below_webp_requirement_{required}"
172        )),
173        None => Err("android_min_sdk_unknown".into()),
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn res_paths_expose_source_set_qualifiers_and_resource_name() {
183        let r = classify(Path::new(
184            "app/src/main/res/drawable-ldrtl-xxhdpi-v26/ic_back.webp",
185        ))
186        .unwrap();
187        assert_eq!(r.module, PathBuf::from("app"));
188        assert_eq!(r.source_set, "main");
189        assert_eq!(r.area, "res");
190        assert_eq!(r.res_type.as_deref(), Some("drawable"));
191        assert_eq!(r.qualifiers, ["ldrtl", "xxhdpi", "v26"]);
192        assert_eq!(r.density.as_deref(), Some("xxhdpi"));
193        assert!(r.rtl);
194        assert_eq!(r.api_level, Some(26));
195        assert_eq!(r.name.as_deref(), Some("ic_back"));
196        assert!(!r.nine_patch);
197        assert_eq!(r.format_lock(), None);
198    }
199
200    #[test]
201    fn nine_patch_launcher_and_raw_files_are_format_locked() {
202        let nine = classify(Path::new("lib/res/drawable-xhdpi/bubble.9.png")).unwrap();
203        assert!(nine.nine_patch);
204        assert_eq!(nine.name.as_deref(), Some("bubble"));
205        assert_eq!(nine.module, PathBuf::from("lib"));
206        assert_eq!(nine.source_set, "");
207        assert_eq!(nine.format_lock(), Some("android_nine_patch"));
208        let icon = classify(Path::new("app/src/main/res/mipmap-xxxhdpi/ic_launcher.png")).unwrap();
209        assert_eq!(icon.format_lock(), Some("android_launcher_icon"));
210        let raw = classify(Path::new("app/src/debug/res/raw/intro.png")).unwrap();
211        assert_eq!(raw.source_set, "debug");
212        assert_eq!(raw.format_lock(), Some("android_raw_resource"));
213    }
214
215    #[test]
216    fn assets_are_path_addressed_and_other_paths_are_not_android() {
217        let asset = classify(Path::new("app/src/main/assets/web/img/logo.png")).unwrap();
218        assert_eq!(asset.area, "assets");
219        assert_eq!(asset.name, None);
220        assert_eq!(asset.format_lock(), None);
221        for path in [
222            "App/Resources/logo.png",
223            "res/logo.png",
224            "res/unknown-type/logo.png",
225            "app/src/main/res/drawable/nested/logo.png",
226            "app/src/main/assets",
227            "../res/drawable/a.png",
228        ] {
229            assert_eq!(classify(Path::new(path)), None, "{path}");
230        }
231    }
232
233    #[test]
234    fn webp_is_gated_by_min_sdk_and_unknown_is_not_assumed() {
235        assert!(webp_compatibility(Some(21), true, true).is_ok());
236        assert!(webp_compatibility(Some(14), false, false).is_ok());
237        assert_eq!(
238            webp_compatibility(Some(16), false, true).unwrap_err(),
239            "android_min_sdk_16_below_webp_requirement_18"
240        );
241        assert_eq!(
242            webp_compatibility(Some(13), false, false).unwrap_err(),
243            "android_min_sdk_13_below_webp_requirement_14"
244        );
245        assert_eq!(
246            webp_compatibility(None, false, false).unwrap_err(),
247            "android_min_sdk_unknown"
248        );
249    }
250}