Engineering

composeResources qualifiers are not Android's, and the fallback differs

A qualifier Android resolves may have no equivalent on iOS or the web.

MobileByteSensei 10 min read
kmpcomposeresources

TL;DR

  • A drawable-night-xxhdpi icon that Android resolved happily and composeResources could not — and the failure was a runtime exception on iOS, not a build error, surfaced by a user in dark mode.
  • The qualifier set differs, and so does the fallback: Android walks a documented precedence chain to a default; composeResources matches a narrower set and throws when nothing matches.
  • A coverage check over the resource tree found 23 qualifier combinations that resolved on Android and had no composeResources equivalent, 19 of them reachable.
  • What reading on buys: the qualifier mapping that actually holds, the gate that turns a runtime throw into a build failure, and the one qualifier we had to stop using entirely.

The case this post is about

An icon set migrated from Android resources into composeResources, keeping the directory names:

shared/src/commonMain/composeResources/
├── drawable/
│   └── ic_sync.xml
├── drawable-night/
│   └── ic_sync.xml                ← dark-mode variant
└── drawable-night-xxhdpi/
    └── ic_sync.png                ← the one that broke
// commonMain
Icon(painterResource(Res.drawable.ic_sync), contentDescription = stringResource(Res.string.sync))

On Android, in dark mode, on an xxhdpi device: the right PNG. Which is what the directory names were chosen to produce, and it worked.

The symptom

A crash report from iOS, dark mode only:

Uncaught Kotlin exception: org.jetbrains.compose.resources.MissingResourceException:
  Missing resource with path: composeResources/drawable-night-xxhdpi/ic_sync.png
    at ResourceReader.read(ResourceReader.kt:41)
    at painterResource$lambda(Painter.kt:88)

A missing-resource exception for a file that is plainly in the source tree, on a code path that works on Android. And the build was green on all five targets, because resource resolution happens at runtime and the generated Res accessor had been produced from the directory listing rather than from a validated qualifier set.

The second symptom was quieter and worse: on the web target, the same icon rendered as the light-mode variant in dark mode. No exception, no log line — just the wrong artwork, which nobody reported for three weeks because the light icon is legible on a dark background.

One cause presenting three different ways across targets, with Android resolving correctly through its own resolver, iOS throwing a missing-resource exception, and the web silently rendering the light variant because a default happened to exist

Why the obvious fix failed

The first fix was to drop the density qualifier, since Compose scales vectors anyway:

drawable-night/ic_sync.png        ← was drawable-night-xxhdpi/

That fixed the exception and produced a blurry icon on high-density Android devices, because the PNG was now being used at its authored size and scaled up. Fixing the resolution failure by discarding the information that made it resolve correctly on one platform.

The second fix was to convert everything to vector drawables, which removes density from the question entirely:

drawable/ic_sync.xml
drawable-night/ic_sync.xml

Correct for icons, and it does not generalise: four of our resources are photographs, and drawable-night-xxhdpi/hero_empty_state.png has no vector form. So the icon set was fixed and the class of problem remained for everything that is genuinely a bitmap.

The third attempt was to look up the supported qualifier list and audit by hand. That is the right instinct and it was unreliable in practice — the list is documented per qualifier type, the combination rules are not, and I produced a hand-audit that missed four cases which the mechanical sweep below then found.

The actual mechanism

Two resource systems with different qualifier vocabularies and, more importantly, different behaviour when nothing matches.

Android's qualifier set is large and its precedence is documented. Locale, layout direction, smallest width, screen size, orientation, UI mode, night mode, density, touchscreen, keyboard availability — in a specified order, and the resolver walks it. A request that matches nothing specific falls back through progressively less-qualified directories to the bare drawable/, and the fallback is guaranteed to terminate at the default.

composeResources supports a subset, and the ones it supports are the portable ones: language, region, theme (-light/-dark), density (-ldpi … -xxxhdpi) and orientation. -night is Android's spelling; composeResources wants -dark. That single difference is the whole incident — drawable-night is not a recognised qualifier, so it is treated as part of a directory name that does not match any known pattern, and such directories are not indexed as variants at all.

And the fallback does not walk the same chain. Android's resolver degrades gracefully; the composeResources reader resolves to a concrete path and throws if the file is not there. So an unrecognised qualifier directory is not a variant that fails to match — it is a variant the indexer never saw, which means the default has to exist, and when the author put the dark variant in an unrecognised directory and no bare-drawable PNG existed at that density, there was nothing to fall back to.

Which explains the three different symptoms from one cause:

  android     its own resolver, recognises -night-xxhdpi      → correct PNG
  ios         composeResources, -night unrecognised,
              no matching default at that density              → MissingResourceException
  web         composeResources, -night unrecognised,
              a bare drawable/ic_sync.xml DID exist            → light variant, silently

The web target had a default to fall back to and the iOS one did not, so the same defect presented as a crash on one platform and wrong artwork on another.

The two resolvers compared on what happens when nothing matches, with one walking a documented precedence chain to a guaranteed default and the other resolving to a concrete path and throwing

The fix

Use the composeResources qualifier vocabulary, and gate the tree so an unrecognised qualifier is a build failure rather than a runtime one.

shared/src/commonMain/composeResources/
├── drawable/
│   ├── ic_sync.xml                      the default — must exist for every resource
│   └── hero_empty_state.png
├── drawable-dark/                       -dark, NOT -night
│   ├── ic_sync.xml
│   └── hero_empty_state.png
├── drawable-xxhdpi/
│   └── hero_empty_state.png
└── drawable-dark-xxhdpi/
    └── hero_empty_state.png

And the gate, which is the durable half — the directory names are the kind of thing that drifts back:

// build-logic/src/main/kotlin/ComposeResourceQualifierCheck.kt
//
// Fails the BUILD on a qualifier composeResources does not recognise, and on a resource with
// no unqualified default. Both are currently runtime failures, which is how a dark-mode icon
// reached a user: resource resolution is not type-checked, and the generated Res accessor is
// produced from the directory listing rather than from a validated qualifier set.
abstract class ComposeResourceQualifierCheck : DefaultTask() {

    @get:InputDirectory abstract val resourceRoot: DirectoryProperty

    /** The qualifiers composeResources actually indexes. Anything else is an unindexed directory. */
    private val known = setOf(
        "light", "dark",
        "ldpi", "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi",
        "land", "port",
    )
    /** Android spellings with a composeResources equivalent — named, so the error can suggest it. */
    private val renamed = mapOf("night" to "dark", "notnight" to "light")

    @TaskAction
    fun check() {
        val root = resourceRoot.get().asFile
        val problems = mutableListOf<String>()
        val defaults = mutableMapOf<String, MutableSet<String>>()   // type → files in the bare dir

        root.listFiles()?.filter { it.isDirectory }?.forEach { dir ->
            val parts = dir.name.split('-')
            val type = parts.first()                                 // drawable, font, values…
            val quals = parts.drop(1)

            if (quals.isEmpty()) {
                defaults.getOrPut(type) { mutableSetOf() } +=
                    dir.listFiles()?.map { it.nameWithoutExtension }.orEmpty()
                return@forEach
            }

            quals.filterNot { it in known }.forEach { q ->
                // A language or region tag is valid and not enumerable, so it is matched by
                // SHAPE rather than listed. Getting this wrong in the strict direction would
                // fail every localised resource, which is how a check gets switched off.
                val isLocale = q.matches(Regex("^[a-z]{2}$")) || q.matches(Regex("^r[A-Z]{2}$"))
                if (isLocale) return@forEach

                problems += renamed[q]?.let { suggestion ->
                    "$dir — '$q' is the ANDROID spelling; composeResources wants '$suggestion'. " +
                    "An unrecognised qualifier directory is not indexed at all, so the resource " +
                    "resolves to the default — or throws MissingResourceException if there is none."
                } ?: "$dir — '$q' is not a composeResources qualifier. Known: ${known.sorted()}"
            }
        }

        // Every qualified variant needs an unqualified sibling, because the reader resolves to
        // a concrete path and throws rather than walking Android's precedence chain.
        root.listFiles()?.filter { it.isDirectory && it.name.contains('-') }?.forEach { dir ->
            val type = dir.name.substringBefore('-')
            dir.listFiles()?.forEach { f ->
                if (f.nameWithoutExtension !in defaults[type].orEmpty()) {
                    problems += "$dir/${f.name} has no default in $type/ — a non-matching " +
                                "request throws instead of falling back."
                }
            }
        }

        if (problems.isNotEmpty()) {
            throw GradleException("composeResources qualifier problems:\n" +
                problems.joinToString("\n") { "  $it" })
        }
    }
}

And the output it produced, run against the tree as it stood:

$ ./gradlew :shared:checkComposeResourceQualifiers

FAILURE: composeResources qualifier problems:
  …/drawable-night — 'night' is the ANDROID spelling; composeResources wants 'dark'.
    An unrecognised qualifier directory is not indexed at all, so the resource resolves to
    the default — or throws MissingResourceException if there is none.
  …/drawable-night-xxhdpi — 'night' is the ANDROID spelling; composeResources wants 'dark'.
    …
  …/drawable-sw600dp — 'sw600dp' is not a composeResources qualifier. Known: [dark, hdpi,
    land, ldpi, light, mdpi, port, xhdpi, xxhdpi, xxxhdpi]
  …/drawable-dark-xxhdpi/hero_empty_state.png has no default in drawable/ — a non-matching
    request throws instead of falling back.

  23 problem(s) across 9 directories

Twenty-three, of which the hand-audit had found nineteen.

How we proved it

That -night was unindexed rather than non-matching — the distinction that explains both symptoms:

$ # the generated accessor, before the rename
$ grep -rn 'ic_sync' shared/build/generated/compose/resourceGenerator/ | head -4
  drawable/ic_sync.xml                          → Res.drawable.ic_sync
  drawable-dark/ic_sync.xml                     → Res.drawable.ic_sync (dark)
# drawable-night/ and drawable-night-xxhdpi/ appear NOWHERE in the generated code

Not a variant that lost a match. A directory the generator never read.

That the same defect presented three ways, which is why one platform's crash report did not describe the problem:

                  default available at that density?    observed
  android          n/a — own resolver                   correct PNG
  ios              no                                   MissingResourceException
  web              yes (a bare vector existed)          light variant, silently

The web row is the one that went unreported for three weeks.

That the hand-audit was incomplete. Worth stating plainly, because the hand-audit is the thing most people will do:

  problems found by hand                19
  problems found by the gate            23
  the four missed:  drawable-sw600dp (×2), values-night, font-night

All four were qualifier types I had not thought to look for — a screen-width qualifier and the -night spelling applied to directories other than drawable. The gate enumerates the tree; I enumerated my expectations.

That the locale escape is necessary and not a hole. A check that rejected unknown qualifiers outright fails every localised resource, which is how a gate gets disabled:

$ ./gradlew :shared:checkComposeResourceQualifiers    # with the locale branch removed
  …/values-fr — 'fr' is not a composeResources qualifier
  …/values-pt-rBR — 'pt' is not a composeResources qualifier
  … 31 more

Thirty-three false failures on a correctly-structured tree.

That the default-sibling rule catches the real crash. A fixture with a dark variant and no default, which is exactly the shipped state:

$ ./gradlew :fixtures:no-default-resource:checkComposeResourceQualifiers
FAILURE: …/drawable-dark-xxhdpi/ic_only_dark.png has no default in drawable/ —
         a non-matching request throws instead of falling back.

What we had to stop using. -sw600dp has no composeResources equivalent, and the two layouts it selected between are now chosen in code from BoxWithConstraints instead. That is more code and it is also more honest: a resource qualifier was making a layout decision invisibly, and the decision is now where a reader can find it. The two drawable-sw600dp assets were merged into one.

What I cannot tell you is whether the known-qualifier set is complete. It is the set composeResources indexed at the version we are on, derived by reading the generator's own source, and it will grow. The gate's failure message lists what it knows, so a legitimate new qualifier produces a clear, locatable failure rather than a silent pass — which is the right direction for a list that will go stale.

The hand audit finding nineteen problems against the mechanical sweep finding twenty-three, with all four misses being qualifier types nobody thought to look for

What this generalizes to

Two systems that share a naming convention do not necessarily share its semantics, and the dangerous case is where one of them treats an unrecognised name as not a variant rather than as an error. A directory that is silently not indexed behaves exactly like one that does not exist, so the failure arrives at the first request that needed it — on whichever platform happens to lack a fallback. Where resolution is by convention rather than by type, the only durable check is one that enumerates the actual tree against the actual recognised set, because a hand-audit enumerates what you remembered to look for.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS