1use serde::{Deserialize, Serialize};
8use std::path::{Component, Path, PathBuf};
9
10pub const WEBP_LOSSY_MIN_SDK: u32 = 14;
12pub 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 pub module: PathBuf,
40 pub source_set: String,
42 pub area: String,
44 pub res_type: Option<String>,
46 pub qualifiers: Vec<String>,
48 pub density: Option<String>,
49 pub rtl: bool,
50 pub api_level: Option<u32>,
52 pub name: Option<String>,
54 pub nine_patch: bool,
55}
56
57impl AndroidResource {
58 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 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
92pub fn classify(path: &Path) -> Option<AndroidResource> {
94 let parts = normal_components(path)?;
95 let file = *parts.last()?;
96 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 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
156pub(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}