use std::borrow::Cow;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use super::table::{self, EntryData, TypeData};
use super::{
Config, Locale, RawValue, ResourceId, ResourceLookup, ResourceName, ResourceNameError, ResourceRef, ResourceType,
StringPoolBuilder, TableError, Value, data_type, resolve,
};
use crate::Content;
use crate::types::{ApiLevel, ArchivePath, ArchivePathError, PackageName};
use crate::xml::{Document, XmlError};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ResourceError {
#[error(transparent)]
Name(#[from] ResourceNameError),
#[error("{ty}/{name} is defined twice for configuration `{config}`")]
Duplicate { ty: ResourceType, name: ResourceName, config: Config },
#[error("{ty} resources cannot be files")]
NotAFileType { ty: ResourceType },
#[error("{ty} resources cannot be compiled XML")]
NotAnXmlType { ty: ResourceType },
#[error("{ty} resources hold bags or IDs, which this compiler does not build")]
Unsupported { ty: ResourceType },
#[error("{ty}/{name} cannot hold this kind of value")]
ValueMismatch { ty: ResourceType, name: ResourceName },
#[error("file resource name {name:?} must use only lowercase letters, digits, and `_`")]
InvalidFileName { name: ResourceName },
#[error("file resource extension {extension:?} is not a lowercase alphanumeric extension")]
InvalidExtension { extension: String },
#[error("{ty}/{name}.xml would be stored as text; add XML resources with `Resources::xml`")]
TextXml { ty: ResourceType, name: ResourceName },
#[error("{name}.9.png is a nine-patch, which must be compiled by aapt2")]
NinePatch { name: ResourceName },
#[error("the path of {ty}/{name} is invalid: {source}")]
Path { ty: ResourceType, name: ResourceName, source: ArchivePathError },
#[error("{ty}/{name} has no default value; devices outside its qualifiers would crash")]
MissingDefault { ty: ResourceType, name: ResourceName },
#[error("{ty}/{name} refers to {reference}, which does not exist")]
UnresolvedReference { ty: ResourceType, name: ResourceName, reference: ResourceRef },
#[error("XML resource {ty}/{name}: {source}")]
Xml { ty: ResourceType, name: ResourceName, source: Box<XmlError> },
#[error(transparent)]
Table(#[from] TableError),
}
impl ResourceType {
pub fn is_file_type(self) -> bool {
matches!(
self,
Self::Anim
| Self::Animator
| Self::Drawable
| Self::Font
| Self::Interpolator
| Self::Layout
| Self::Menu
| Self::Mipmap
| Self::Navigation
| Self::Raw
| Self::Transition
| Self::Xml
)
}
fn is_bag_type(self) -> bool {
matches!(self, Self::Array | Self::Attr | Self::Id | Self::Plurals | Self::Style)
}
fn accepts(self, value: &Value) -> bool {
use {ResourceType as T, Value as V};
matches!(
(self, value),
(_, V::Reference(_))
| (T::String, V::String(_))
| (T::Bool, V::Bool(_))
| (T::Integer, V::Int(_) | V::Hex(_))
| (T::Color | T::Drawable | T::Mipmap, V::Color(_))
| (T::Dimen, V::Dimension(_))
| (T::Fraction, V::Fraction(_))
)
}
}
#[derive(Clone, Debug, PartialEq)]
enum Item {
Value(Value),
File {
path: ArchivePath,
content: Content,
},
Xml {
path: ArchivePath,
document: Document,
},
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Resources {
types: BTreeMap<ResourceType, BTreeMap<ResourceName, BTreeMap<Config, Item>>>,
}
impl Resources {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.types.is_empty()
}
pub fn contains(&self, ty: ResourceType, name: &str) -> bool {
self.variants(ty, name).is_some()
}
pub fn contains_variant(&self, ty: ResourceType, name: &str, config: Config) -> bool {
self.variants(ty, name).is_some_and(|variants| variants.contains_key(&config.with_implied_sdk_version()))
}
fn variants(&self, ty: ResourceType, name: &str) -> Option<&BTreeMap<Config, Item>> {
self.types.get(&ty)?.get(name)
}
pub fn value(
&mut self,
ty: ResourceType,
name: &str,
config: Config,
value: impl Into<Value>,
) -> Result<&mut Self, ResourceError> {
let name = ResourceName::try_new(name)?;
let value = value.into();
if ty.is_bag_type() {
return Err(ResourceError::Unsupported { ty });
}
if !ty.accepts(&value) {
return Err(ResourceError::ValueMismatch { ty, name });
}
self.insert(ty, name, config, |_| Ok(Item::Value(value)))
}
pub fn string(&mut self, name: &str, value: impl Into<String>) -> Result<&mut Self, ResourceError> {
self.value(ResourceType::String, name, Config::default(), Value::String(value.into()))
}
pub fn localized_string(
&mut self,
name: &str,
locale: Locale,
value: impl Into<String>,
) -> Result<&mut Self, ResourceError> {
self.value(ResourceType::String, name, Config::for_locale(locale), Value::String(value.into()))
}
pub fn file(
&mut self,
ty: ResourceType,
name: &str,
config: Config,
extension: &str,
content: impl Into<Content>,
) -> Result<&mut Self, ResourceError> {
let name = ResourceName::try_new(name)?;
if !ty.is_file_type() {
return Err(ResourceError::NotAFileType { ty });
}
let valid = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
if !extension.split('.').all(valid) {
return Err(ResourceError::InvalidExtension { extension: extension.to_owned() });
}
if extension == "xml" && ty != ResourceType::Raw {
return Err(ResourceError::TextXml { ty, name });
}
if extension == "9.png" {
return Err(ResourceError::NinePatch { name });
}
let content = content.into();
self.insert(ty, name, config, |slot| Ok(Item::File { path: slot.path(extension)?, content }))
}
pub fn xml(
&mut self,
ty: ResourceType,
name: &str,
config: Config,
document: Document,
) -> Result<&mut Self, ResourceError> {
let name = ResourceName::try_new(name)?;
if !(ty.is_file_type() || ty == ResourceType::Color) || ty == ResourceType::Raw {
return Err(ResourceError::NotAnXmlType { ty });
}
self.insert(ty, name, config, |slot| Ok(Item::Xml { path: slot.path("xml")?, document }))
}
fn insert(
&mut self,
ty: ResourceType,
name: ResourceName,
config: Config,
make: impl FnOnce(&Slot<'_>) -> Result<Item, ResourceError>,
) -> Result<&mut Self, ResourceError> {
let config = config.with_implied_sdk_version();
if self.variants(ty, &name).is_some_and(|variants| variants.contains_key(&config)) {
return Err(ResourceError::Duplicate { ty, name, config });
}
let item = make(&Slot { ty, name: &name, config })?;
self.types.entry(ty).or_default().entry(name).or_default().insert(config, item);
Ok(self)
}
pub fn compile(&self, package: &PackageName, min_sdk: ApiLevel) -> Result<CompiledResources<'_>, ResourceError> {
let ids = Ids(self
.types
.iter()
.enumerate()
.flat_map(|(type_index, (&ty, entries))| {
entries.keys().enumerate().map(move |(entry, name)| {
let id = ResourceId::new(ResourceId::APP_PACKAGE, type_index as u8 + 1, entry as u16);
(ty, name, id)
})
})
.collect());
let mut strings = StringPoolBuilder::default();
let mut files = Vec::new();
let mut types = Vec::with_capacity(self.types.len());
for (&ty, entries) in &self.types {
let mut data = TypeData { ty, entries: Vec::with_capacity(entries.len()) };
for (name, variants) in entries {
let mut variants: Vec<(Config, u16, &Item)> = variants
.iter()
.map(|(&config, item)| {
let version = config.sdk_version();
let implied = u32::from(version) <= min_sdk.get();
(if implied { config.with_sdk_version(0) } else { config }, version, item)
})
.collect();
variants.sort_by_key(|&(config, version, _)| (config, Reverse(version)));
variants.dedup_by_key(|&mut (config, _, _)| config);
if !ty.is_file_type() && !variants.iter().any(|(config, _, _)| config.is_default()) {
return Err(ResourceError::MissingDefault { ty, name: name.clone() });
}
let mut values = Vec::with_capacity(variants.len());
for (config, _, item) in variants {
let value = match item {
Item::Value(value) => {
let unresolved = |reference: &ResourceRef| ResourceError::UnresolvedReference {
ty,
name: name.clone(),
reference: reference.clone(),
};
let lookup =
|reference: &ResourceRef| resolve(reference, &ids).ok_or_else(|| unresolved(reference));
value.to_raw(|s| strings.intern(s), lookup)?
}
Item::File { path, content } => {
files.push((path, Cow::Borrowed(content)));
RawValue::new(data_type::STRING, strings.intern(path.as_str()))
}
Item::Xml { path, document } => {
let xml = |source| ResourceError::Xml { ty, name: name.clone(), source: Box::new(source) };
let bytes = document.encode(&ids).map_err(xml)?;
files.push((path, Cow::Owned(Content::from(bytes))));
RawValue::new(data_type::STRING, strings.intern(path.as_str()))
}
};
values.push((config, value));
}
data.entries.push(EntryData { name: name.as_str(), values });
}
types.push(data);
}
let table = table::write(ResourceId::APP_PACKAGE, package.as_str(), &strings, &types)?;
files.sort_by_key(|&(path, _)| path);
Ok(CompiledResources { table, files, ids })
}
}
struct Slot<'a> {
ty: ResourceType,
name: &'a ResourceName,
config: Config,
}
impl Slot<'_> {
fn path(&self, extension: &str) -> Result<ArchivePath, ResourceError> {
let Self { ty, name, config } = *self;
if !name.is_file_name() {
return Err(ResourceError::InvalidFileName { name: name.clone() });
}
let path = match config.is_default() {
true => format!("res/{ty}/{name}.{extension}"),
false => format!("res/{ty}-{config}/{name}.{extension}"),
};
ArchivePath::try_new(path).map_err(|source| ResourceError::Path { ty, name: name.clone(), source })
}
}
#[derive(Clone, Debug, PartialEq)]
struct Ids<'a>(Vec<(ResourceType, &'a ResourceName, ResourceId)>);
impl Ids<'_> {
fn get(&self, ty: ResourceType, name: &str) -> Option<ResourceId> {
let index = self.0.binary_search_by(|&(t, n, _)| (t, n.as_str()).cmp(&(ty, name))).ok()?;
Some(self.0[index].2)
}
}
impl ResourceLookup for Ids<'_> {
fn lookup(&self, ty: ResourceType, name: &ResourceName) -> Option<ResourceId> {
self.get(ty, name)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CompiledResources<'a> {
pub table: Vec<u8>,
pub files: Vec<(&'a ArchivePath, Cow<'a, Content>)>,
ids: Ids<'a>,
}
impl CompiledResources<'_> {
pub fn id(&self, ty: ResourceType, name: &str) -> Option<ResourceId> {
self.ids.get(ty, name)
}
}
impl ResourceLookup for CompiledResources<'_> {
fn lookup(&self, ty: ResourceType, name: &ResourceName) -> Option<ResourceId> {
self.ids.lookup(ty, name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::android::attr;
use crate::res::{Color, Density, EntryValue, ResourceTable};
use crate::xml::Element;
fn package() -> PackageName {
PackageName::try_new("org.example.app").unwrap()
}
#[test]
fn assigns_ids_and_writes_the_table() {
let mut resources = Resources::new();
resources.string("app_name", "Hello").unwrap();
resources.localized_string("app_name", "fr".parse().unwrap(), "Bonjour").unwrap();
resources.string("about", "About").unwrap();
resources
.file(ResourceType::Mipmap, "ic_launcher", Config::for_density(Density::XHDPI), "png", b"png")
.unwrap();
let compiled = resources.compile(&package(), ApiLevel::N).unwrap();
assert_eq!(compiled.id(ResourceType::Mipmap, "ic_launcher"), Some(ResourceId(0x7f01_0000)));
assert_eq!(compiled.id(ResourceType::String, "about"), Some(ResourceId(0x7f02_0000)));
assert_eq!(compiled.id(ResourceType::String, "app_name"), Some(ResourceId(0x7f02_0001)));
assert_eq!(compiled.files[0].0.as_str(), "res/mipmap-xhdpi-v4/ic_launcher.png");
let table = ResourceTable::parse(&compiled.table).unwrap();
let app_name = table.get(ResourceId(0x7f02_0001)).unwrap();
assert_eq!(app_name.value(&"fr".parse().unwrap()), Some(EntryValue::Simple("Bonjour".into())));
assert_eq!(table.find(0x7f, ResourceType::Mipmap, "ic_launcher"), Some(ResourceId(0x7f01_0000)));
}
#[test]
fn compiles_xml_resources_against_app_ids() {
let mut resources = Resources::new();
resources.value(ResourceType::Color, "background", Config::default(), Color::rgb(0x112233)).unwrap();
resources.file(ResourceType::Mipmap, "foreground", Config::default(), "png", b"png").unwrap();
let reference = |text: &str| text.parse::<ResourceRef>().unwrap();
let icon = Element::new("adaptive-icon")
.with_android_namespace()
.with_child(Element::new("background").with_attr(attr::DRAWABLE, reference("@color/background")))
.with_child(Element::new("foreground").with_attr(attr::DRAWABLE, reference("@mipmap/foreground")));
resources.xml(ResourceType::Mipmap, "ic_launcher", "anydpi-v26".parse().unwrap(), Document::new(icon)).unwrap();
let compiled = resources.compile(&package(), ApiLevel::N).unwrap();
let (path, content) = compiled.files.iter().find(|(p, _)| p.extension() == Some("xml")).unwrap();
assert_eq!(path.as_str(), "res/mipmap-anydpi-v26/ic_launcher.xml");
let Content::Bytes(bytes) = content.as_ref() else { panic!("compiled XML is in memory") };
let decoded = Document::decode(bytes).unwrap();
let background = decoded.root.element("background").unwrap().android_attr(attr::DRAWABLE).unwrap();
let id = compiled.id(ResourceType::Color, "background").unwrap();
assert_eq!(background, &Value::Reference(id.into()));
}
#[test]
fn versions_the_minimum_sdk_implies_are_collapsed() {
let mut resources = Resources::new();
let color = |rgb| Color::rgb(rgb);
resources
.value(ResourceType::Color, "tint", Config::default(), color(0x000000))
.unwrap()
.value(ResourceType::Color, "tint", "night".parse().unwrap(), color(0x111111))
.unwrap()
.value(ResourceType::Color, "tint", "v21".parse().unwrap(), color(0x222222))
.unwrap()
.value(ResourceType::Color, "tint", "v23".parse().unwrap(), color(0x333333))
.unwrap()
.value(ResourceType::Color, "tint", "v26".parse().unwrap(), color(0x444444))
.unwrap();
let compiled = resources.compile(&package(), ApiLevel::N).unwrap();
let table = ResourceTable::parse(&compiled.table).unwrap();
let tint = table.get(compiled.id(ResourceType::Color, "tint").unwrap()).unwrap();
let values: Vec<(String, String)> = tint.values().map(|(c, v)| (c.to_string(), v.to_string())).collect();
assert_eq!(
values,
[("", "#333333"), ("night", "#111111"), ("v26", "#444444")].map(|(c, v)| (c.into(), v.into()))
);
}
#[test]
fn rejects_inconsistent_definitions() {
let mut resources = Resources::new();
resources.string("title", "A").unwrap();
assert!(matches!(resources.string("title", "B"), Err(ResourceError::Duplicate { .. })));
assert!(matches!(
resources.value(ResourceType::Bool, "flag", Config::default(), "yes"),
Err(ResourceError::ValueMismatch { .. })
));
assert!(matches!(
resources.file(ResourceType::Mipmap, "Icon", Config::default(), "png", b""),
Err(ResourceError::InvalidFileName { .. })
));
assert!(matches!(
resources.file(ResourceType::String, "x", Config::default(), "txt", b""),
Err(ResourceError::NotAFileType { .. })
));
let mut untranslated = Resources::new();
untranslated.localized_string("greeting", "de".parse().unwrap(), "Hallo").unwrap();
assert!(matches!(untranslated.compile(&package(), ApiLevel::N), Err(ResourceError::MissingDefault { .. })));
}
#[test]
fn rejects_resources_it_cannot_build() {
let mut resources = Resources::new();
let document = || Document::new(Element::new("selector"));
assert!(matches!(
resources.value(ResourceType::Style, "Theme", Config::default(), Value::Null),
Err(ResourceError::Unsupported { ty: ResourceType::Style })
));
assert!(matches!(
resources.file(ResourceType::Drawable, "shape", Config::default(), "xml", b"<shape/>"),
Err(ResourceError::TextXml { .. })
));
assert!(matches!(
resources.file(ResourceType::Drawable, "button", Config::default(), "9.png", b""),
Err(ResourceError::NinePatch { .. })
));
assert!(matches!(
resources.xml(ResourceType::Raw, "config", Config::default(), document()),
Err(ResourceError::NotAnXmlType { ty: ResourceType::Raw })
));
resources.xml(ResourceType::Color, "tint", Config::default(), document()).unwrap();
resources.file(ResourceType::Raw, "config", Config::default(), "xml", b"<config/>").unwrap();
}
#[test]
fn overlong_paths_are_errors_not_panics() {
let (name, extension) = ("a".repeat(30_000), "b".repeat(40_000));
let mut resources = Resources::new();
let result = resources.file(ResourceType::Raw, &name, Config::default(), &extension, b"");
assert!(matches!(result, Err(ResourceError::Path { .. })));
}
}