composeResources qualifiers are not Android's, and the fallback differs
A qualifier Android resolves may have no equivalent on iOS or the web.
TL;DR
- A
drawable-night-xxhdpiicon that Android resolved happily andcomposeResourcescould 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.

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

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.
Related
- Compose for web loads a runtime before it draws anything — those fonts, and what fetching them costs on the one target that has to.
- The same dp is a different number of pixels on every target — the density qualifier's runtime counterpart.
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".