Skip to main contentSkip to docs navigation

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.

TEXT
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
        └── ...
TypeWhat Android getsNames
FontsTTF and OTF files, and the license of each fontsnake_case
ImagesEvery format, in density folderssnake_case, indicator removed
IconsSVG files onlysnake_case with the ic_ prefix
Any other folderEvery filesnake_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:

Shell
mkdir -p chassis-assets-dist/chassis
unzip "chassis-assets-android-demo-chassis-<version>.zip" -d chassis-assets-dist/chassis

Git 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.

Shell
git lfs install
git submodule add https://github.com/chassis-ui/assets.git chassis-assets
cd chassis-assets
pnpm assets --platform android

Build 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.

Shell
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 job

A 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/.

TEXT
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:

Shell
cd chassis-assets
pnpm install
pnpm assets --platform android --res --vector-drawables

The font files, the raster images and the vector drawables move into res/, without the subfolders they had:

TEXT
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:

Shell
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:

Shell
#!/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
done

The 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:

Shell
./scripts/sync-assets.sh chassis                                   # from chassis-assets/dist/
ASSETS_ROOT=chassis-assets-dist ./scripts/sync-assets.sh chassis   # from an unpacked archive

When 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
<?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:

XML
<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:

KOTLIN
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/.

IndicatorFolder
Nonedrawable/
@1xdrawable-mdpi/
@1.5xdrawable-hdpi/
@2xdrawable-xhdpi/
@3xdrawable-xxhdpi/
@4xdrawable-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:

TEXT
dist/android/demo/chassis/images/logo/
├── drawable/
│   └── chassis_logo_brand.png
├── drawable-xhdpi/
│   └── chassis_logo_brand.png
└── drawable-xxhdpi/
    └── chassis_logo_brand.png

The 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:

XML
<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:

KOTLIN
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:

Shell
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.

TEXT
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:

Shell
cd chassis-assets
pnpm install
pnpm assets --platform android --vector-drawables

Each SVG file under icons/ is then an XML file of the same name, which res/drawable/ takes as it is:

TEXT
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:

Shell
# 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:

XML
<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:

KOTLIN
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:

KOTLIN
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:

KOTLIN
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:

YAML
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 assemble

With 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 in scripts/sync-assets.sh, so a new file of source/ reaches res/ with the next build.
  • Keep the licenses: ship the <role>_license.txt files of fonts/ with the app, from assets/licenses/.
  • Name flavors after brands: a flavor named as the brand in chassis.build.brands maps dist/android/<app>/<brand>/ to app/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 @2x and @3x exports in source/.
  • Pin the assets: use a submodule commit or the archive of one version, so a build of the app is repeatable.
  • Build with --webp for smaller images: the option, which needs pnpm 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 in source/ 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: