Android Applications
Where the Android output of Chassis Assets is, how its fonts, images and icons are named, and how to copy them into the res/ folder of an app.
Introduction
The Android output of a brand and an app is the folder dist/android/<app>/<brand>/. The build copies the files of source/ into it, renames them to snake_case, and sorts the images into density folders. In the committed configuration the Android app is demo and the brands are chassis and sinefil; your own configuration has other names in the same places.
dist/android/demo/chassis/
├── fonts/
│ ├── text_normal.ttf
│ ├── text_license.txt
│ └── ...
├── images/
│ ├── drawable/
│ │ └── chassis_logo_shadow.png
│ └── drawable-xhdpi/
│ └── chassis_logo_shadow.png
└── icons/
└── svgs/
├── ic_arrow_right_solid.svg
└── ...| Type | What Android gets | Names |
|---|---|---|
| Fonts | TTF and OTF files, and the license of each font | snake_case |
| Images | Every format, in density folders | snake_case, indicator removed |
| Icons | SVG files only | snake_case with the ic_ prefix |
| Any other folder | Every file | snake_case |
The default output is not a res/ folder. The build keeps the subfolders of images/, which res/ does not allow, and it copies an SVG as SVG, which res/ does not accept. You copy the files you need into res/, or build with --res, see Res folder. For the icons, build with --vector-drawables or import the SVG files with Android Studio, see Vector drawables.
Installation
An app gets the files in one of three ways: from a release archive, from a Git submodule that it builds, or from a build in a clone. The samples of this page read the output from chassis-assets/dist/android/demo/, with the repository in the folder chassis-assets/ at the root of the Android project.
Release archive
A release archive holds the output of one platform, one app and one brand, and needs neither Node.js nor Git LFS. From version 0.2.0 on, each GitHub release has one archive per job attached, named chassis-assets-<platform>-<app>-<brand>-<version>.zip. The archive holds the content of dist/<platform>/<app>/<brand>/ at its top level, so unpack it into a folder named after the brand:
mkdir -p chassis-assets-dist/chassis
unzip "chassis-assets-android-demo-chassis-<version>.zip" -d chassis-assets-dist/chassisGit submodule
A submodule pins the app to one commit of the assets and lets the app build them itself. You need Node.js 22.12 or later, pnpm, and Git LFS, which holds the fonts and the raster images.
git lfs install
git submodule add https://github.com/chassis-ui/assets.git chassis-assets
cd chassis-assets
pnpm assets --platform androidBuild from a clone
A clone beside the app works the same way, and suits a team that changes the assets and the app together. The build imports Node.js modules only, so it runs without pnpm install.
git clone https://github.com/chassis-ui/assets.git chassis-assets
cd chassis-assets
pnpm assets --platform android # every brand and app of the platform
pnpm assets --platform android --brand chassis --app demo # one jobA build with a filter keeps the rest of dist/; add --clean to remove the folders of the selected jobs first. See the build system page for every option.
Output layout
The job folder holds one folder per type of the app. The build writes fonts/ flat, puts drawable/ and the density folders under images/ and under each of its subfolders, and keeps the subfolders of icons/.
dist/android/demo/chassis/
├── fonts/
│ ├── code_license.txt
│ ├── code_normal.ttf
│ ├── display_license.txt
│ ├── display_normal.ttf
│ ├── text_license.txt
│ ├── text_normal.ttf
│ ├── text_normal_italic.ttf
│ ├── text_strong.ttf
│ └── ...
├── icons/
│ ├── icons/
│ │ └── ic_chassis_icons.svg
│ └── svgs/
│ ├── ic_arrow_right_outline.svg
│ ├── ic_arrow_right_solid.svg
│ └── ...
└── images/
├── drawable/
│ ├── chassis_logo.svg
│ ├── chassis_logo_shadow.png
│ └── ...
├── drawable-xhdpi/
│ └── chassis_logo_shadow.png
├── home/
│ ├── drawable/
│ │ └── ...
│ └── drawable-xhdpi/
│ └── ...
└── logo/
├── drawable/
│ ├── chassis_logo_brand.png
│ ├── chassis_logo_brand.svg
│ └── ...
├── drawable-xhdpi/
│ ├── chassis_logo_brand.png
│ └── ...
└── drawable-xxhdpi/
├── chassis_logo_brand.png
└── ...Res folder
The build writes a res/ folder itself when you give it the --res option. Add --vector-drawables, which needs the packages of the repository, to get the icons into it:
cd chassis-assets
pnpm install
pnpm assets --platform android --res --vector-drawablesThe font files, the raster images and the vector drawables move into res/, without the subfolders they had:
dist/android/demo/chassis/
├── fonts/
│ ├── code_license.txt
│ ├── display_license.txt
│ └── text_license.txt
├── icons/
│ └── icons/
│ └── ic_chassis_icons.svg
├── images/
│ ├── drawable/
│ │ ├── chassis_logo.svg
│ │ └── ...
│ ├── home/
│ └── logo/
└── res/
├── drawable/
│ ├── chassis_logo_brand.png
│ ├── chassis_logo_shadow.png
│ ├── ic_arrow_right_solid.xml
│ └── ...
├── drawable-xhdpi/
│ ├── chassis_logo_brand.png
│ ├── chassis_logo_shadow.png
│ └── ...
├── drawable-xxhdpi/
│ ├── chassis_logo_brand.png
│ └── ...
└── font/
├── code_normal.ttf
├── text_normal.ttf
└── ...Copy the folder into the source set of the brand, and the licenses beside it:
BRAND=chassis
ASSETS="chassis-assets/dist/android/demo/$BRAND"
mkdir -p "app/src/$BRAND/assets/licenses"
cp -R "$ASSETS/res" "app/src/$BRAND/"
cp "$ASSETS"/fonts/*_license.txt "app/src/$BRAND/assets/licenses/"What res/ does not take stays in its folder, and the build warns about the number of such files: the licenses, the SVG images, and without --vector-drawables every icon. When two subfolders of images/ hold an image of one name, the folder nearest to images/ gets the resource and the other file stays. The release archives are built without the option, and the rest of this page describes the default output.
Copying to res
Without the option, a copy script moves the files that res/ accepts as they are: the font files and the PNG and WebP images. It lists the folders of images/ that the app uses, because res/ has one drawable/ folder and the output has one per subfolder. Add this script to the Android project as scripts/sync-assets.sh:
#!/bin/bash
# scripts/sync-assets.sh <brand>
set -euo pipefail
BRAND="${1:-chassis}"
ASSETS="${ASSETS_ROOT:-chassis-assets/dist/android/demo}/$BRAND"
SRC="app/src/$BRAND"
# Fonts go to res/font/, their licenses to assets/licenses/
mkdir -p "$SRC/res/font" "$SRC/assets/licenses"
find "$ASSETS/fonts" -maxdepth 1 \( -name '*.ttf' -o -name '*.otf' \) -exec cp {} "$SRC/res/font/" \;
cp "$ASSETS"/fonts/*_license.txt "$SRC/assets/licenses/"
# The PNG and WebP files of each folder of images/ that the app uses
for folder in images images/home images/logo; do
for density in "$ASSETS/$folder"/drawable*/; do
target="$SRC/res/$(basename "$density")"
mkdir -p "$target"
find "$density" -maxdepth 1 \( -name '*.png' -o -name '*.webp' \) -exec cp {} "$target/" \;
done
doneThe script writes to the source set of the brand, app/src/<brand>/, which Brand switching uses; an app with one brand sets SRC to app/src/main. With release archives, set ASSETS_ROOT to the folder that holds the unpacked brands:
./scripts/sync-assets.sh chassis # from chassis-assets/dist/
ASSETS_ROOT=chassis-assets-dist ./scripts/sync-assets.sh chassis # from an unpacked archiveWhen two listed folders hold a PNG of the same name in the same density folder, the folder listed last wins.
The script copies and replaces files and never removes one. A file that a later build does not write again stays in app/src/<brand>/res/: an image that was renamed, or the PNG of an image that a build with --webp now writes as WebP. Delete such a file by hand, see Duplicate resources.
Fonts
The build gives Android the TTF and OTF files of fonts/ and the <role>-license.txt of each font, renamed to snake_case. A font file is named after its role and its style, text_normal.ttf or display_strong_italic.ttf, so two brands with different typefaces have the same file names. See Fonts for the roles and the source layout.
Font resources
The copy script puts the font files in res/font/, where each one becomes a resource named after the file: R.font.text_normal. The licenses go to assets/licenses/, because res/font/ accepts only font files and XML, and the app still has to ship the license with the font.
Font families
A font family resource groups the files of one role, so a layout asks for the role and a weight. Add res/font/text.xml with one entry for each file the app uses:
<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:android="http://schemas.android.com/apk/res/android">
<font android:font="@font/text_normal" android:fontStyle="normal" android:fontWeight="400" />
<font android:font="@font/text_normal_italic" android:fontStyle="italic" android:fontWeight="400" />
<font android:font="@font/text_strong" android:fontStyle="normal" android:fontWeight="600" />
<font android:font="@font/text_strong_italic" android:fontStyle="italic" android:fontWeight="600" />
</font-family>The file name does not hold the weight. normal and strong are names of Chassis Tokens, and the number behind each one is a choice of the brand: the 400 and 600 above are examples, so take the numbers from the tokens of your brand. The attribute needs a literal number, so a brand with other weights needs its own text.xml in app/src/<brand>/res/font/.
The attributes are in the android: namespace, which every view reads from API 26 on. A family that has only app: attributes, the namespace of AndroidX, loads in an AppCompatTextView and not in a plain TextView or any other view of the framework: the text shows in the system font, without an error. An app that supports API 25 or lower repeats each attribute with app: beside the android: one.
Using a font
A view takes the family by its resource name, and Jetpack Compose builds a FontFamily from the same files. Use the family in a layout:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/text"
android:text="@string/title" />Use the files in Jetpack Compose:
val TextFamily = FontFamily(
Font(R.font.text_normal, FontWeight.Normal),
Font(R.font.text_normal_italic, FontWeight.Normal, FontStyle.Italic),
Font(R.font.text_strong, FontWeight.SemiBold),
Font(R.font.text_strong_italic, FontWeight.SemiBold, FontStyle.Italic)
)
Text(text = "Title", fontFamily = TextFamily, fontWeight = FontWeight.SemiBold)Images
The build copies every image format and sorts the files by their indicator. See Images for the source layout and the variants.
Density folders
The build removes the indicator from the name and writes the file to the density folder that the indicator stands for, under the folder the image is in. An image without an indicator goes to drawable/.
| Indicator | Folder |
|---|---|
| None | drawable/ |
@1x | drawable-mdpi/ |
@1.5x | drawable-hdpi/ |
@2x | drawable-xhdpi/ |
@3x | drawable-xxhdpi/ |
@4x | drawable-xxxhdpi/ |
The source files images/logo/chassis-logo-brand.png, chassis-logo-brand@2x.png and chassis-logo-brand@3x.png of the app come out as one name in three folders:
dist/android/demo/chassis/images/logo/
├── drawable/
│ └── chassis_logo_brand.png
├── drawable-xhdpi/
│ └── chassis_logo_brand.png
└── drawable-xxhdpi/
└── chassis_logo_brand.pngThe build does not resize: an image has the variants that the designer exported, and no others. Android reads a file of drawable/ as an mdpi image and scales it up on a denser screen.
Using an image
After the copy script ran, the three files are one resource, R.drawable.chassis_logo_brand, and Android picks the file of the screen density. Use it in a layout:
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/chassis_logo_brand"
android:contentDescription="@string/logo" />Use it in Jetpack Compose:
Image(
painter = painterResource(R.drawable.chassis_logo_brand),
contentDescription = stringResource(R.string.logo)
)SVG images
The build copies an SVG image to drawable/ as SVG, and res/drawable/ does not accept that format, so the copy script leaves the SVG files out. Import the ones you need with Vector Asset Studio of Android Studio: File → New → Vector Asset, Local file (SVG, PSD), then the file, for example images/drawable/chassis_logo.svg of the job folder. Android Studio writes res/drawable/chassis_logo.xml.
Several images of images/logo/ exist as both PNG and SVG with one name, such as chassis_logo_brand.png and chassis_logo_brand.svg. A resource has one file per folder, so use one of the two formats for a name, or give the vector drawable another name in the dialog.
Night images
The build has no rule for dark mode: an image for a dark background is a file of its own with its own name, such as comp_gallery_dark.png beside comp_gallery_light.png in images/home/. To let Android choose between the two, copy them to one resource name, the dark one into a folder with the night qualifier:
HOME_IMAGES="chassis-assets/dist/android/demo/chassis/images/home"
RES="app/src/main/res"
mkdir -p "$RES/drawable-xhdpi" "$RES/drawable-night-xhdpi"
cp "$HOME_IMAGES/drawable-xhdpi/comp_gallery_light.png" "$RES/drawable-xhdpi/comp_gallery.png"
cp "$HOME_IMAGES/drawable-xhdpi/comp_gallery_dark.png" "$RES/drawable-night-xhdpi/comp_gallery.png"The app then reads R.drawable.comp_gallery in both modes.
Icons
The build keeps only the SVG files under icons/. It adds the ic_ prefix to a name that does not have it, and renames the files to snake_case. See Icons for where the icons come from.
dist/android/demo/chassis/icons/
├── icons/
│ └── ic_chassis_icons.svg
└── svgs/
├── ic_arrow_right_outline.svg
├── ic_arrow_right_solid.svg
├── ic_bars_outline.svg
├── ic_bars_solid.svg
└── ...The files of icons/svgs/ are the icons, one file per icon and style. icons/icons/ic_chassis_icons.svg is the SVG sprite of Chassis Icons, which an Android app has no use for.
Vector drawables
An Android app draws an icon from a vector drawable. The build writes one for each icon when you give it the --vector-drawables option, which needs the packages of the repository:
cd chassis-assets
pnpm install
pnpm assets --platform android --vector-drawablesEach SVG file under icons/ is then an XML file of the same name, which res/drawable/ takes as it is:
dist/android/demo/chassis/icons/
├── icons/
│ └── ic_chassis_icons.svg
└── svgs/
├── ic_arrow_right_outline.xml
├── ic_arrow_right_solid.xml
├── ic_bars_outline.xml
├── ic_bars_solid.xml
└── ...A path without a fill color is filled black, which the tint of the app replaces. A file with no shape to draw stays an SVG, and the build warns about it: icons/icons/ic_chassis_icons.svg is the one of this output. Add the icons to the copy script of Copying to res:
# The vector drawables of the icons, from a build with --vector-drawables
mkdir -p "$SRC/res/drawable"
cp "$ASSETS"/icons/svgs/*.xml "$SRC/res/drawable/"The release archives are built without the option and hold the SVG files. From an SVG file you make the vector drawable with Vector Asset Studio: File → New → Vector Asset, Local file (SVG, PSD), then a file of icons/svgs/. Keep the name that the dialog proposes, so the resource has the name of the file: ic_arrow_right_solid.svg becomes res/drawable/ic_arrow_right_solid.xml. Import the icons the app uses, not the whole folder, and import an icon again when its SVG changes.
Using an icon
A vector drawable is a drawable resource like an image, and takes its color from a tint. Use it in a layout:
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_arrow_right_solid"
android:contentDescription="@string/next"
app:tint="?attr/colorOnSurface" />Use it in Jetpack Compose:
Icon(
painter = painterResource(R.drawable.ic_arrow_right_solid),
contentDescription = stringResource(R.string.next),
tint = MaterialTheme.colorScheme.onSurface
)Other assets
The build copies any other folder of the app whole, with its subfolders, and renames its files to snake_case; a dot inside a name becomes an underscore too. The committed demo app has no such folder. A source file data/brand.tokens.json would come out as dist/android/<app>/<brand>/data/brand_tokens.json.
Copy such a file to res/raw/ to read it as R.raw.brand_tokens, or copy the folder to app/src/main/assets/ to keep its subfolders and read a file by its path. See Other assets for what the folder can hold.
Brand switching
Every brand of chassis.build.brands has a job folder with the same file names, so an app changes brand by changing the folder it copies from. A brand file replaces the default file of the same path in the build; the names stay, and the code of the app does not change.
Product flavors
A product flavor per brand gives each brand its own source set, app/src/<brand>/, which is where the copy script writes. Name the flavors after the brands in app/build.gradle.kts:
android {
flavorDimensions += "brand"
productFlavors {
create("chassis") { dimension = "brand" }
create("sinefil") { dimension = "brand" }
}
}./gradlew assembleChassisDebug then builds the app with the files of app/src/chassis/, and ./gradlew assembleSinefilDebug with those of app/src/sinefil/. Put the vector drawables and the text.xml that differ by brand in the same source sets, and the ones that all brands share in app/src/main/res/.
Gradle task
A Gradle task runs the copy script before every build, so the source sets follow the output. Add one task per brand to app/build.gradle.kts:
listOf("chassis", "sinefil").forEach { brand ->
val sync = tasks.register<Exec>("sync${brand.replaceFirstChar { it.uppercase() }}Assets") {
workingDir = rootDir
commandLine("scripts/sync-assets.sh", brand)
}
tasks.named("preBuild") { dependsOn(sync) }
}The task copies; it does not build the assets. Run pnpm assets --platform android in chassis-assets/ after a change to source/ or after moving the submodule, or leave pnpm assets --watch --platform android running there, which builds the Android jobs again on every change under source/.
An app that holds every brand in one APK and switches at run time cannot use the names as they are, since the brands share them: it needs a script that adds the brand to each name while copying.
Continuous integration
A workflow builds the Android jobs, runs the copy script for each brand, then builds the app. The script, the Gradle tasks and the build of the job were run on a Mac with Gradle 9.8 and Android Gradle Plugin 9.4; the workflow itself was not run on GitHub Actions. The build needs only Node.js, so the workflow runs it with node, which is what pnpm assets does, and installs nothing in chassis-assets/. This workflow is for GitHub Actions, with the assets as a submodule:
name: Android
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
submodules: true
- name: Fetch the Git LFS files of the assets
run: git -C chassis-assets lfs pull
- uses: actions/setup-node@v7
with:
node-version-file: chassis-assets/.nvmrc
- name: Build the Android assets
working-directory: chassis-assets
run: node packages/assets/build/cli.js build --platform android
- name: Copy the assets
run: |
./scripts/sync-assets.sh chassis
./scripts/sync-assets.sh sinefil
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
- name: Build the app
run: ./gradlew assembleWith release archives the workflow needs neither the submodule nor Node.js: it downloads the archive of each brand, unpacks it as in Release archive, and runs the copy script with ASSETS_ROOT.
Best practices
These practices keep the copy from dist/android/ into res/ repeatable, so every file of the app comes from a build of the assets.
- Copy by a script: keep the list of the
images/folders the app uses inscripts/sync-assets.sh, so a new file ofsource/reachesres/with the next build. - Keep the licenses: ship the
<role>_license.txtfiles offonts/with the app, fromassets/licenses/. - Name flavors after brands: a flavor named as the brand in
chassis.build.brandsmapsdist/android/<app>/<brand>/toapp/src/<brand>/without a table of names. - Ask for the variants: the build does not resize, so an image that has only a
drawable/file needs@2xand@3xexports insource/. - Pin the assets: use a submodule commit or the archive of one version, so a build of the app is repeatable.
- Build with
--webpfor smaller images: the option, which needspnpm install, writes a lossless WebP in place of each PNG that it makes smaller, under the same resource name.
Avoid the habits that undo this:
- Do not edit the copies: a file under
app/src/<brand>/res/that the script wrote is replaced by the next run. Change the file insource/and build again. - Do not rename in the app: the names of
dist/android/are the same for every brand. A renamed copy breaks that for the next brand. - Do not keep one image as PNG and as WebP in
res/: the Android output has both when the designer exported both, and a resource has one file per folder. Remove the extension the app does not use from the copy script. - Do not commit
dist/: it is build output. Commit the submodule pointer, or name the version of the archive.
Troubleshooting
Each entry gives the message or the symptom, then the cause and the fix.
Git LFS pointers
The build stops with <count> source file(s) are Git LFS pointers, not files, followed by the first of them. The clone or the submodule was made without Git LFS, so the fonts and the raster images are text files of a few lines. Install Git LFS, run git lfs pull in chassis-assets/, and build again.
Unknown brand
The build stops with Unknown brand "<name>". Configured: <brands>. The value of --brand is not in chassis.build.brands of package.json; --app and --platform fail the same way. Use a configured name, or add the brand to the configuration.
Copy script fails
The copy script stops at a cp line with No such file or directory. The job folder is missing, because the Android build did not run or ASSETS_ROOT points elsewhere, or the brand has no file of that extension in fonts/. Run pnpm assets --platform android, check the path, and remove the pattern that matches nothing from the script.
SVG file in res
Gradle fails on a file of res/drawable/ with The file name must end with .xml or .png. An SVG of images/ or icons/ was copied as it is, and res/ does not accept SVG. Remove the file and import it with Vector Asset Studio, as in SVG images, or build the icons with --vector-drawables, as in Vector drawables.
Duplicate resources
Gradle fails with Duplicate resources and names two files of one folder, such as chassis_logo_brand.png and chassis_logo_brand.xml. The image exists as PNG and as SVG in images/logo/, and both were added to res/drawable/. Keep one of them, or import the SVG under another name. The two files can also be a copy of an earlier build and one of this build: chassis_logo_brand.png is still in app/src/chassis/res/drawable/ when a build with --webp writes chassis_logo_brand.webp in its place. Delete the file of the earlier build.
Image is missing
An image of source/ is not where you look for it in the job folder. An image with an indicator is in a density folder under the subfolder of the image, not in drawable/, and its name is in snake_case without the indicator. Search for the name with find dist/android/demo/chassis/images -name '<name>*'.
Image looks blurry
An image is soft on a dense screen. It has only a file in drawable/, which Android scales up. Add the @2x and @3x variants to source/, build, and run the copy script.
Icon is not found
The compiler reports Unresolved reference for an icon such as R.drawable.ic_arrow_right. The name of an icon ends with its style, and the vector drawable exists only after the copy or the import: check the name with ls dist/android/demo/chassis/icons/svgs, and use ic_arrow_right_solid or ic_arrow_right_outline.
Next steps
Continue with one of these pages:
- Build System: the commands, their options and the configuration of brands and apps.
- Fonts, Images, Icons and Other assets: where each type goes in
source/and how it is named. - iOS Applications and Web Applications: the same files on the other platforms.
- Add custom fonts, Vector Asset Studio and product flavors in the Android documentation.