Skip to main content

apk/res/
resources.rs

1//! Compiling app resources into a resource table.
2
3use 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/// Why resources could not be added or compiled.
17#[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    /// Whether resources of this type are files under `res/`.
54    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    /// Whether resources of this type are bags (or IDs), which have no
73    /// simple value.
74    fn is_bag_type(self) -> bool {
75        matches!(self, Self::Array | Self::Attr | Self::Id | Self::Plurals | Self::Style)
76    }
77
78    /// Whether `value` is a simple value of this type.
79    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/// The contents of one resource variant.
95#[derive(Clone, Debug, PartialEq)]
96enum Item {
97    Value(Value),
98    /// A file stored as is.
99    File {
100        path: ArchivePath,
101        content: Content,
102    },
103    /// A document compiled to binary XML.
104    Xml {
105        path: ArchivePath,
106        document: Document,
107    },
108}
109
110/// An application's resources: values, files, and XML documents, each
111/// defined for one or more configurations.
112///
113/// IDs are assigned when compiling, by type and then by name, so adding a
114/// resource can change the IDs of others. Like aapt2, the compiler adds the
115/// minimum API level a configuration's qualifiers imply (`night` is stored
116/// as `night-v8`).
117///
118/// ```
119/// use apk::res::{Config, Density, Resources, ResourceType};
120///
121/// let mut resources = Resources::new();
122/// resources.string("app_name", "Postcard")?;
123/// resources.localized_string("app_name", "fr".parse()?, "Carte postale")?;
124/// let png = vec![0x89, b'P', b'N', b'G'];
125/// resources.file(ResourceType::Mipmap, "ic_launcher", Config::for_density(Density::XHDPI), "png", png)?;
126/// # Ok::<(), apk::Error>(())
127/// ```
128#[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    /// Whether a resource with this type and name exists in any configuration.
143    pub fn contains(&self, ty: ResourceType, name: &str) -> bool {
144        self.variants(ty, name).is_some()
145    }
146
147    /// Whether a resource with this type and name is defined for `config`.
148    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    /// Adds a simple value: a string, bool, integer, color, dimension,
157    /// fraction, or a reference to another resource.
158    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    /// Adds the default value of string resource `name`.
177    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    /// Adds a translation of string resource `name`.
182    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    /// Adds a file stored as is, at `res/<type>[-<qualifiers>]/<name>.<extension>`.
192    /// XML (except in raw files) must be added with [`xml`](Self::xml),
193    /// which compiles it.
194    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    /// Adds an XML file resource, such as an adaptive icon, a color state
221    /// list, or a network security config, compiled to binary XML. Its
222    /// references may name other resources in this set.
223    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    /// Adds the item `make` builds for the slot `(ty, name, config)`.
238    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    /// Assigns IDs and writes the resource table for `package`, for an app
255    /// supporting `min_sdk` onwards.
256    ///
257    /// Like aapt2, version qualifiers the minimum SDK already satisfies are
258    /// dropped from the table (`night-v8` becomes `night`); when that makes
259    /// variants collide, the one with the highest version is kept.
260    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                    // Out-of-range indices are rejected when the table is written.
268                    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
328/// Where a file resource of a given type, name, and configuration is stored.
329struct Slot<'a> {
330    ty: ResourceType,
331    name: &'a ResourceName,
332    config: Config,
333}
334
335impl Slot<'_> {
336    /// `res/<type>[-<qualifiers>]/<name>.<extension>`.
337    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/// The IDs assigned by [`Resources::compile`], ordered by type and name.
351#[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/// The output of [`Resources::compile`], borrowing the resources' files.
368#[derive(Clone, Debug, PartialEq)]
369pub struct CompiledResources<'a> {
370    /// The encoded `resources.arsc`.
371    pub table: Vec<u8>,
372    /// Files to store in the APK, by path.
373    pub files: Vec<(&'a ArchivePath, Cow<'a, Content>)>,
374    ids: Ids<'a>,
375}
376
377impl CompiledResources<'_> {
378    /// The ID assigned to resource `name` of type `ty`.
379    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        // Types are numbered in `ResourceType` order, entries by name.
413        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        // `v23` outranks `v21` and the unversioned default once API 24 is assumed.
465        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        // Color state lists are XML; raw XML is stored as is.
514        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}