1use std::borrow::Cow;
4use std::cmp::Reverse;
5use std::collections::BTreeMap;
6
7use super::table::{self, EntryData, TypeData};
8use super::{
9 Config, Locale, RawValue, ResourceId, ResourceLookup, ResourceName, ResourceNameError, ResourceRef, ResourceType,
10 StringPoolBuilder, TableError, Value, data_type, resolve,
11};
12use crate::Content;
13use crate::types::{ApiLevel, ArchivePath, ArchivePathError, PackageName};
14use crate::xml::{Document, XmlError};
15
16#[derive(Debug, thiserror::Error)]
18#[non_exhaustive]
19pub enum ResourceError {
20 #[error(transparent)]
21 Name(#[from] ResourceNameError),
22 #[error("{ty}/{name} is defined twice for configuration `{config}`")]
23 Duplicate { ty: ResourceType, name: ResourceName, config: Config },
24 #[error("{ty} resources cannot be files")]
25 NotAFileType { ty: ResourceType },
26 #[error("{ty} resources cannot be compiled XML")]
27 NotAnXmlType { ty: ResourceType },
28 #[error("{ty} resources hold bags or IDs, which this compiler does not build")]
29 Unsupported { ty: ResourceType },
30 #[error("{ty}/{name} cannot hold this kind of value")]
31 ValueMismatch { ty: ResourceType, name: ResourceName },
32 #[error("file resource name {name:?} must use only lowercase letters, digits, and `_`")]
33 InvalidFileName { name: ResourceName },
34 #[error("file resource extension {extension:?} is not a lowercase alphanumeric extension")]
35 InvalidExtension { extension: String },
36 #[error("{ty}/{name}.xml would be stored as text; add XML resources with `Resources::xml`")]
37 TextXml { ty: ResourceType, name: ResourceName },
38 #[error("{name}.9.png is a nine-patch, which must be compiled by aapt2")]
39 NinePatch { name: ResourceName },
40 #[error("the path of {ty}/{name} is invalid: {source}")]
41 Path { ty: ResourceType, name: ResourceName, source: ArchivePathError },
42 #[error("{ty}/{name} has no default value; devices outside its qualifiers would crash")]
43 MissingDefault { ty: ResourceType, name: ResourceName },
44 #[error("{ty}/{name} refers to {reference}, which does not exist")]
45 UnresolvedReference { ty: ResourceType, name: ResourceName, reference: ResourceRef },
46 #[error("XML resource {ty}/{name}: {source}")]
47 Xml { ty: ResourceType, name: ResourceName, source: Box<XmlError> },
48 #[error(transparent)]
49 Table(#[from] TableError),
50}
51
52impl ResourceType {
53 pub fn is_file_type(self) -> bool {
55 matches!(
56 self,
57 Self::Anim
58 | Self::Animator
59 | Self::Drawable
60 | Self::Font
61 | Self::Interpolator
62 | Self::Layout
63 | Self::Menu
64 | Self::Mipmap
65 | Self::Navigation
66 | Self::Raw
67 | Self::Transition
68 | Self::Xml
69 )
70 }
71
72 fn is_bag_type(self) -> bool {
75 matches!(self, Self::Array | Self::Attr | Self::Id | Self::Plurals | Self::Style)
76 }
77
78 fn accepts(self, value: &Value) -> bool {
80 use {ResourceType as T, Value as V};
81 matches!(
82 (self, value),
83 (_, V::Reference(_))
84 | (T::String, V::String(_))
85 | (T::Bool, V::Bool(_))
86 | (T::Integer, V::Int(_) | V::Hex(_))
87 | (T::Color | T::Drawable | T::Mipmap, V::Color(_))
88 | (T::Dimen, V::Dimension(_))
89 | (T::Fraction, V::Fraction(_))
90 )
91 }
92}
93
94#[derive(Clone, Debug, PartialEq)]
96enum Item {
97 Value(Value),
98 File {
100 path: ArchivePath,
101 content: Content,
102 },
103 Xml {
105 path: ArchivePath,
106 document: Document,
107 },
108}
109
110#[derive(Clone, Debug, Default, PartialEq)]
129pub struct Resources {
130 types: BTreeMap<ResourceType, BTreeMap<ResourceName, BTreeMap<Config, Item>>>,
131}
132
133impl Resources {
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn is_empty(&self) -> bool {
139 self.types.is_empty()
140 }
141
142 pub fn contains(&self, ty: ResourceType, name: &str) -> bool {
144 self.variants(ty, name).is_some()
145 }
146
147 pub fn contains_variant(&self, ty: ResourceType, name: &str, config: Config) -> bool {
149 self.variants(ty, name).is_some_and(|variants| variants.contains_key(&config.with_implied_sdk_version()))
150 }
151
152 fn variants(&self, ty: ResourceType, name: &str) -> Option<&BTreeMap<Config, Item>> {
153 self.types.get(&ty)?.get(name)
154 }
155
156 pub fn value(
159 &mut self,
160 ty: ResourceType,
161 name: &str,
162 config: Config,
163 value: impl Into<Value>,
164 ) -> Result<&mut Self, ResourceError> {
165 let name = ResourceName::try_new(name)?;
166 let value = value.into();
167 if ty.is_bag_type() {
168 return Err(ResourceError::Unsupported { ty });
169 }
170 if !ty.accepts(&value) {
171 return Err(ResourceError::ValueMismatch { ty, name });
172 }
173 self.insert(ty, name, config, |_| Ok(Item::Value(value)))
174 }
175
176 pub fn string(&mut self, name: &str, value: impl Into<String>) -> Result<&mut Self, ResourceError> {
178 self.value(ResourceType::String, name, Config::default(), Value::String(value.into()))
179 }
180
181 pub fn localized_string(
183 &mut self,
184 name: &str,
185 locale: Locale,
186 value: impl Into<String>,
187 ) -> Result<&mut Self, ResourceError> {
188 self.value(ResourceType::String, name, Config::for_locale(locale), Value::String(value.into()))
189 }
190
191 pub fn file(
195 &mut self,
196 ty: ResourceType,
197 name: &str,
198 config: Config,
199 extension: &str,
200 content: impl Into<Content>,
201 ) -> Result<&mut Self, ResourceError> {
202 let name = ResourceName::try_new(name)?;
203 if !ty.is_file_type() {
204 return Err(ResourceError::NotAFileType { ty });
205 }
206 let valid = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
207 if !extension.split('.').all(valid) {
208 return Err(ResourceError::InvalidExtension { extension: extension.to_owned() });
209 }
210 if extension == "xml" && ty != ResourceType::Raw {
211 return Err(ResourceError::TextXml { ty, name });
212 }
213 if extension == "9.png" {
214 return Err(ResourceError::NinePatch { name });
215 }
216 let content = content.into();
217 self.insert(ty, name, config, |slot| Ok(Item::File { path: slot.path(extension)?, content }))
218 }
219
220 pub fn xml(
224 &mut self,
225 ty: ResourceType,
226 name: &str,
227 config: Config,
228 document: Document,
229 ) -> Result<&mut Self, ResourceError> {
230 let name = ResourceName::try_new(name)?;
231 if !(ty.is_file_type() || ty == ResourceType::Color) || ty == ResourceType::Raw {
232 return Err(ResourceError::NotAnXmlType { ty });
233 }
234 self.insert(ty, name, config, |slot| Ok(Item::Xml { path: slot.path("xml")?, document }))
235 }
236
237 fn insert(
239 &mut self,
240 ty: ResourceType,
241 name: ResourceName,
242 config: Config,
243 make: impl FnOnce(&Slot<'_>) -> Result<Item, ResourceError>,
244 ) -> Result<&mut Self, ResourceError> {
245 let config = config.with_implied_sdk_version();
246 if self.variants(ty, &name).is_some_and(|variants| variants.contains_key(&config)) {
247 return Err(ResourceError::Duplicate { ty, name, config });
248 }
249 let item = make(&Slot { ty, name: &name, config })?;
250 self.types.entry(ty).or_default().entry(name).or_default().insert(config, item);
251 Ok(self)
252 }
253
254 pub fn compile(&self, package: &PackageName, min_sdk: ApiLevel) -> Result<CompiledResources<'_>, ResourceError> {
261 let ids = Ids(self
262 .types
263 .iter()
264 .enumerate()
265 .flat_map(|(type_index, (&ty, entries))| {
266 entries.keys().enumerate().map(move |(entry, name)| {
267 let id = ResourceId::new(ResourceId::APP_PACKAGE, type_index as u8 + 1, entry as u16);
269 (ty, name, id)
270 })
271 })
272 .collect());
273 let mut strings = StringPoolBuilder::default();
274 let mut files = Vec::new();
275 let mut types = Vec::with_capacity(self.types.len());
276 for (&ty, entries) in &self.types {
277 let mut data = TypeData { ty, entries: Vec::with_capacity(entries.len()) };
278 for (name, variants) in entries {
279 let mut variants: Vec<(Config, u16, &Item)> = variants
280 .iter()
281 .map(|(&config, item)| {
282 let version = config.sdk_version();
283 let implied = u32::from(version) <= min_sdk.get();
284 (if implied { config.with_sdk_version(0) } else { config }, version, item)
285 })
286 .collect();
287 variants.sort_by_key(|&(config, version, _)| (config, Reverse(version)));
288 variants.dedup_by_key(|&mut (config, _, _)| config);
289 if !ty.is_file_type() && !variants.iter().any(|(config, _, _)| config.is_default()) {
290 return Err(ResourceError::MissingDefault { ty, name: name.clone() });
291 }
292 let mut values = Vec::with_capacity(variants.len());
293 for (config, _, item) in variants {
294 let value = match item {
295 Item::Value(value) => {
296 let unresolved = |reference: &ResourceRef| ResourceError::UnresolvedReference {
297 ty,
298 name: name.clone(),
299 reference: reference.clone(),
300 };
301 let lookup =
302 |reference: &ResourceRef| resolve(reference, &ids).ok_or_else(|| unresolved(reference));
303 value.to_raw(|s| strings.intern(s), lookup)?
304 }
305 Item::File { path, content } => {
306 files.push((path, Cow::Borrowed(content)));
307 RawValue::new(data_type::STRING, strings.intern(path.as_str()))
308 }
309 Item::Xml { path, document } => {
310 let xml = |source| ResourceError::Xml { ty, name: name.clone(), source: Box::new(source) };
311 let bytes = document.encode(&ids).map_err(xml)?;
312 files.push((path, Cow::Owned(Content::from(bytes))));
313 RawValue::new(data_type::STRING, strings.intern(path.as_str()))
314 }
315 };
316 values.push((config, value));
317 }
318 data.entries.push(EntryData { name: name.as_str(), values });
319 }
320 types.push(data);
321 }
322 let table = table::write(ResourceId::APP_PACKAGE, package.as_str(), &strings, &types)?;
323 files.sort_by_key(|&(path, _)| path);
324 Ok(CompiledResources { table, files, ids })
325 }
326}
327
328struct Slot<'a> {
330 ty: ResourceType,
331 name: &'a ResourceName,
332 config: Config,
333}
334
335impl Slot<'_> {
336 fn path(&self, extension: &str) -> Result<ArchivePath, ResourceError> {
338 let Self { ty, name, config } = *self;
339 if !name.is_file_name() {
340 return Err(ResourceError::InvalidFileName { name: name.clone() });
341 }
342 let path = match config.is_default() {
343 true => format!("res/{ty}/{name}.{extension}"),
344 false => format!("res/{ty}-{config}/{name}.{extension}"),
345 };
346 ArchivePath::try_new(path).map_err(|source| ResourceError::Path { ty, name: name.clone(), source })
347 }
348}
349
350#[derive(Clone, Debug, PartialEq)]
352struct Ids<'a>(Vec<(ResourceType, &'a ResourceName, ResourceId)>);
353
354impl Ids<'_> {
355 fn get(&self, ty: ResourceType, name: &str) -> Option<ResourceId> {
356 let index = self.0.binary_search_by(|&(t, n, _)| (t, n.as_str()).cmp(&(ty, name))).ok()?;
357 Some(self.0[index].2)
358 }
359}
360
361impl ResourceLookup for Ids<'_> {
362 fn lookup(&self, ty: ResourceType, name: &ResourceName) -> Option<ResourceId> {
363 self.get(ty, name)
364 }
365}
366
367#[derive(Clone, Debug, PartialEq)]
369pub struct CompiledResources<'a> {
370 pub table: Vec<u8>,
372 pub files: Vec<(&'a ArchivePath, Cow<'a, Content>)>,
374 ids: Ids<'a>,
375}
376
377impl CompiledResources<'_> {
378 pub fn id(&self, ty: ResourceType, name: &str) -> Option<ResourceId> {
380 self.ids.get(ty, name)
381 }
382}
383
384impl ResourceLookup for CompiledResources<'_> {
385 fn lookup(&self, ty: ResourceType, name: &ResourceName) -> Option<ResourceId> {
386 self.ids.lookup(ty, name)
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::android::attr;
394 use crate::res::{Color, Density, EntryValue, ResourceTable};
395 use crate::xml::Element;
396
397 fn package() -> PackageName {
398 PackageName::try_new("org.example.app").unwrap()
399 }
400
401 #[test]
402 fn assigns_ids_and_writes_the_table() {
403 let mut resources = Resources::new();
404 resources.string("app_name", "Hello").unwrap();
405 resources.localized_string("app_name", "fr".parse().unwrap(), "Bonjour").unwrap();
406 resources.string("about", "About").unwrap();
407 resources
408 .file(ResourceType::Mipmap, "ic_launcher", Config::for_density(Density::XHDPI), "png", b"png")
409 .unwrap();
410 let compiled = resources.compile(&package(), ApiLevel::N).unwrap();
411
412 assert_eq!(compiled.id(ResourceType::Mipmap, "ic_launcher"), Some(ResourceId(0x7f01_0000)));
414 assert_eq!(compiled.id(ResourceType::String, "about"), Some(ResourceId(0x7f02_0000)));
415 assert_eq!(compiled.id(ResourceType::String, "app_name"), Some(ResourceId(0x7f02_0001)));
416 assert_eq!(compiled.files[0].0.as_str(), "res/mipmap-xhdpi-v4/ic_launcher.png");
417
418 let table = ResourceTable::parse(&compiled.table).unwrap();
419 let app_name = table.get(ResourceId(0x7f02_0001)).unwrap();
420 assert_eq!(app_name.value(&"fr".parse().unwrap()), Some(EntryValue::Simple("Bonjour".into())));
421 assert_eq!(table.find(0x7f, ResourceType::Mipmap, "ic_launcher"), Some(ResourceId(0x7f01_0000)));
422 }
423
424 #[test]
425 fn compiles_xml_resources_against_app_ids() {
426 let mut resources = Resources::new();
427 resources.value(ResourceType::Color, "background", Config::default(), Color::rgb(0x112233)).unwrap();
428 resources.file(ResourceType::Mipmap, "foreground", Config::default(), "png", b"png").unwrap();
429 let reference = |text: &str| text.parse::<ResourceRef>().unwrap();
430 let icon = Element::new("adaptive-icon")
431 .with_android_namespace()
432 .with_child(Element::new("background").with_attr(attr::DRAWABLE, reference("@color/background")))
433 .with_child(Element::new("foreground").with_attr(attr::DRAWABLE, reference("@mipmap/foreground")));
434 resources.xml(ResourceType::Mipmap, "ic_launcher", "anydpi-v26".parse().unwrap(), Document::new(icon)).unwrap();
435 let compiled = resources.compile(&package(), ApiLevel::N).unwrap();
436 let (path, content) = compiled.files.iter().find(|(p, _)| p.extension() == Some("xml")).unwrap();
437 assert_eq!(path.as_str(), "res/mipmap-anydpi-v26/ic_launcher.xml");
438 let Content::Bytes(bytes) = content.as_ref() else { panic!("compiled XML is in memory") };
439 let decoded = Document::decode(bytes).unwrap();
440 let background = decoded.root.element("background").unwrap().android_attr(attr::DRAWABLE).unwrap();
441 let id = compiled.id(ResourceType::Color, "background").unwrap();
442 assert_eq!(background, &Value::Reference(id.into()));
443 }
444
445 #[test]
446 fn versions_the_minimum_sdk_implies_are_collapsed() {
447 let mut resources = Resources::new();
448 let color = |rgb| Color::rgb(rgb);
449 resources
450 .value(ResourceType::Color, "tint", Config::default(), color(0x000000))
451 .unwrap()
452 .value(ResourceType::Color, "tint", "night".parse().unwrap(), color(0x111111))
453 .unwrap()
454 .value(ResourceType::Color, "tint", "v21".parse().unwrap(), color(0x222222))
455 .unwrap()
456 .value(ResourceType::Color, "tint", "v23".parse().unwrap(), color(0x333333))
457 .unwrap()
458 .value(ResourceType::Color, "tint", "v26".parse().unwrap(), color(0x444444))
459 .unwrap();
460 let compiled = resources.compile(&package(), ApiLevel::N).unwrap();
461 let table = ResourceTable::parse(&compiled.table).unwrap();
462 let tint = table.get(compiled.id(ResourceType::Color, "tint").unwrap()).unwrap();
463 let values: Vec<(String, String)> = tint.values().map(|(c, v)| (c.to_string(), v.to_string())).collect();
464 assert_eq!(
466 values,
467 [("", "#333333"), ("night", "#111111"), ("v26", "#444444")].map(|(c, v)| (c.into(), v.into()))
468 );
469 }
470
471 #[test]
472 fn rejects_inconsistent_definitions() {
473 let mut resources = Resources::new();
474 resources.string("title", "A").unwrap();
475 assert!(matches!(resources.string("title", "B"), Err(ResourceError::Duplicate { .. })));
476 assert!(matches!(
477 resources.value(ResourceType::Bool, "flag", Config::default(), "yes"),
478 Err(ResourceError::ValueMismatch { .. })
479 ));
480 assert!(matches!(
481 resources.file(ResourceType::Mipmap, "Icon", Config::default(), "png", b""),
482 Err(ResourceError::InvalidFileName { .. })
483 ));
484 assert!(matches!(
485 resources.file(ResourceType::String, "x", Config::default(), "txt", b""),
486 Err(ResourceError::NotAFileType { .. })
487 ));
488 let mut untranslated = Resources::new();
489 untranslated.localized_string("greeting", "de".parse().unwrap(), "Hallo").unwrap();
490 assert!(matches!(untranslated.compile(&package(), ApiLevel::N), Err(ResourceError::MissingDefault { .. })));
491 }
492
493 #[test]
494 fn rejects_resources_it_cannot_build() {
495 let mut resources = Resources::new();
496 let document = || Document::new(Element::new("selector"));
497 assert!(matches!(
498 resources.value(ResourceType::Style, "Theme", Config::default(), Value::Null),
499 Err(ResourceError::Unsupported { ty: ResourceType::Style })
500 ));
501 assert!(matches!(
502 resources.file(ResourceType::Drawable, "shape", Config::default(), "xml", b"<shape/>"),
503 Err(ResourceError::TextXml { .. })
504 ));
505 assert!(matches!(
506 resources.file(ResourceType::Drawable, "button", Config::default(), "9.png", b""),
507 Err(ResourceError::NinePatch { .. })
508 ));
509 assert!(matches!(
510 resources.xml(ResourceType::Raw, "config", Config::default(), document()),
511 Err(ResourceError::NotAnXmlType { ty: ResourceType::Raw })
512 ));
513 resources.xml(ResourceType::Color, "tint", Config::default(), document()).unwrap();
515 resources.file(ResourceType::Raw, "config", Config::default(), "xml", b"<config/>").unwrap();
516 }
517
518 #[test]
519 fn overlong_paths_are_errors_not_panics() {
520 let (name, extension) = ("a".repeat(30_000), "b".repeat(40_000));
521 let mut resources = Resources::new();
522 let result = resources.file(ResourceType::Raw, &name, Config::default(), &extension, b"");
523 assert!(matches!(result, Err(ResourceError::Path { .. })));
524 }
525}