Engineering

composeResources is not Android resources, and qualifiers resolve differently

Same folder names, different resolution. The accessor is typed, and a missing qualifier fails somewhere new.

MobileByteSensei 8 min read
kmpcomposeresources

TL;DR

composeResources borrows Android's folder vocabulary — values-fr, drawable-xxhdpi, values-night — and implements its own resolver. The names match; the mechanism does not.

Three consequences. Access is through a generated typed accessor (Res.string.greeting), so a missing resource is a compile error rather than a runtime crash. Reads are suspending or composable, because on some targets the bytes are fetched. And a missing qualifier falls back to the unqualified default on every platform — including iOS and wasm, which have no Android resource system to borrow behaviour from.

Treat it as its own system with familiar folder names, not as Android resources that happen to be multiplatform.

The case this post is about

A shared feature needing localised strings and a density-appropriate icon. The folder layout is the one every Android developer already knows:

commonMain/composeResources/
├── values/strings.xml            en — the default
├── values-fr/strings.xml         French
├── values-ar/strings.xml         Arabic
├── drawable/ic_sync.xml
└── drawable-xxhdpi/ic_sync.png

So the code everyone writes first is the Android code:

// in a @Composable
Text(stringResource(R.string.sync_now))               // ← no R class exists
Icon(painterResource(R.drawable.ic_sync), null)

That does not compile, which is the best thing that happens in this post.

The symptom

Four separate confusions, in the order a team hits them.

No R class. The generated accessor is an object, named per module:

Text(stringResource(Res.string.sync_now))        // import …generated.resources.Res

Reading outside composition needs a suspend call. A ViewModel formatting an error message cannot call stringResource — it is @Composable:

// in a ViewModel
val msg = stringResource(Res.string.sync_failed)   // error: @Composable invocation
val msg = getString(Res.string.sync_failed)        // suspend — works, but why?

The Arabic layout did not mirror. values-ar supplied the strings and the UI stayed left-to-right, because composeResources resolves resources and has nothing to do with layout direction.

And the one that reached production. A values-pt folder was added for Portuguese. On Android, Brazilian users got Portuguese. On iOS they got English, and nobody noticed for a release, because English is a plausible-looking result rather than a crash.

Why the obvious fix failed

"Add the Android resource plugin as well." Two resource systems, two accessors, and the shared module cannot use the Android one at all. This also produces the subtle problem of a string existing twice with different values.

"values-pt must be the wrong qualifier name." It is a correct BCP-47-ish language code and Android resolves it. My first hour went into the folder name because the Android mental model says qualifier problems are naming problems.

"The iOS bundle is missing the resource." A reasonable theory, and checking the built framework showed values-pt present and packaged. The bytes were there; the resolution did not select them.

"Set the locale explicitly at startup." This does work as a blunt instrument and it masks the question. I wanted to know why the default resolution differed, and forcing a locale means never finding out.

The actual mechanism

composeResources is a Gradle plugin plus a runtime resolver. Three parts, each diverging from Android somewhere.

Codegen, not an R class. The plugin scans composeResources/ and generates a typed Res object. Every resource is a property with a compile-time identity, so a typo or a deleted file is a build failure — a real improvement over Android's integer IDs, where a stale R reference compiles and crashes.

A runtime resolver, not the platform's. This is the core of it. Android's resource system is part of the OS: it reads the device configuration and selects from the compiled resource table. composeResources ships its own resolver, because there is no such system on iOS, desktop, or wasm. It reads a locale from the platform, matches against the qualified directories it knows about, and falls back to unqualified.

Asynchronous reads on some targets. On wasm, resource bytes may need fetching, so the generic read path is suspend. The @Composable variants exist because composition can suspend internally and re-emit when the value arrives.

So the Portuguese bug was a locale matching difference. The device reported pt-BR. Android's resolver has a documented fallback chain that reaches values-pt from pt-BR. The composeResources resolver, in the version we were on, matched more strictly — pt-BR did not select values-pt, so it fell through to the unqualified default and returned English.

The same folder vocabulary resolved by two different systems: Android's OS resource table reaching values-pt from a pt-BR device locale through its documented fallback chain, and the composeResources runtime resolver matching more strictly, missing values-pt, and falling through to the unqualified default — producing plausible English rather than a crash

The rule:

Borrowed vocabulary is not borrowed behaviour. composeResources uses Android's folder names so the layout is familiar; the resolution is its own, and it is the resolution that decides what a user sees.

And the sub-case that makes this harder to catch than it should be: the codegen's type safety does not extend to translations. Res.string.checkout_total is generated from the unqualified values/strings.xml. That is the file that defines the typed surface, so the compile-time guarantee is "this key exists in the default locale" — not "this key exists in the locale the user is running".

Which means the two failure modes land on opposite sides of the build:

key missing from values/strings.xml      → compile error, caught in seconds
key missing from values-pt/strings.xml   → compiles, ships, returns English

The second is the same silent fallback as the pt-BR bug arriving by a different route, and the typed Res object actively encourages the wrong intuition: everything about it feels checked. It is checked — for one locale. Every other locale is still a runtime lookup with a plausible answer waiting when it misses, which is why the coverage test below is not optional hygiene but the only thing standing where the compiler stops.

The fix

Name the qualifiers the resolver will actually match, and make coverage a test rather than an assumption.

Add the region-qualified folders your users actually report. Not instead of the language folder — alongside it:

values/strings.xml          en          default
values-pt/strings.xml       pt          generic Portuguese
values-pt-rBR/strings.xml   pt-BR       Brazilian — matched exactly
values-fr/strings.xml
values-ar/strings.xml

This is slightly redundant on Android, which would have resolved pt-BR → pt by itself. The redundancy is the price of one behaviour on every platform, and being explicit is what makes the result predictable rather than dependent on which resolver you are standing on.

Read strings off the composition through the suspend API, deliberately. A ViewModel that needs a localised string should not pretend to be a composable:

// commonMain
class SyncViewModel(private val strings: StringProvider) {
    suspend fun onFailure() = state.update {
        it.copy(error = strings.get(Res.string.sync_failed))
    }
}

// commonMain — an interface, so tests do not need the resource runtime
interface StringProvider { suspend fun get(res: StringResource): String }

class ComposeStringProvider : StringProvider {
    override suspend fun get(res: StringResource) = getString(res)
}

The seam matters for a reason beyond tidiness: getString needs the resource runtime, and a commonTest ViewModel test that goes through a fake provider runs on every target with no resource machinery at all.

Layout direction is a separate decision, made from the locale. composeResources will not do it:

val layoutDirection = if (currentLocaleIsRtl()) LayoutDirection.Rtl else LayoutDirection.Ltr
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { AppRoot() }

And a coverage test, because a missing translation is silent by design. This is the part that turns the production bug into a build failure:

// commonTest
@Test
fun everyStringExistsInEveryDeclaredLocale() = runTest {
    val missing = mutableListOf<String>()
    for (locale in DECLARED_LOCALES) {          // "pt", "pt-BR", "fr", "ar"
        for (key in ALL_STRING_KEYS) {
            if (!hasTranslation(locale, key)) missing += "$locale/$key"
        }
    }
    assertTrue(missing.isEmpty(), "missing translations:\n" + missing.joinToString("\n"))
}

Fallback to the default is the correct runtime behaviour — a user should get English rather than an empty screen. It is the wrong build behaviour, and that is exactly the pairing to aim for: a graceful runtime and a strict build.

How we proved it

The claim is about resolution, so the measurement has to vary the device locale and read what comes back — per platform, because the whole point is that they differed.

device locale        Android        iOS            wasm (browser pt-BR)
pt-BR
  before            Português      English        English
  after             Português      Português      Português
pt-PT
  before            Português      English        English
  after             Português      Português      Português
fr-CA
  before            Français       Français       Français      ← values-fr matched

The fr-CA row is the one that made the mechanism clear rather than just the bug: French worked on every platform before the fix, so "iOS ignores qualifiers" was never the right description. Something specific about pt-BR → pt was not matching, which is a fallback-chain difference and not a packaging or naming problem.

The coverage test, run against a deliberately incomplete locale:

// remove one key from values-pt
everyStringExistsInEveryDeclaredLocale  FAILED
  missing translations:
  pt/sync_now

Four targets, same failure, before any device is involved.

Two limits worth stating plainly. These resolution results are from the plugin version we were on — fallback behaviour has been actively improved across releases, so the correct move on a new project is to measure your own matrix rather than inherit this table; the durable lesson is that the resolver is independent of Android's, not the specific gap. And the coverage test reads the resource XML as files, so it verifies a translation exists, not that it is accurate. A present-but-wrong translation needs a human.

What this generalizes to

Familiar names invite you to assume familiar semantics. This is the whole trap. The folder layout being identical to Android's is a usability win that costs you one wrong assumption per behaviour you never checked. When an API borrows another system's vocabulary, the question is which behaviours came with it.

Graceful fallback at runtime, strict failure at build time. A silent fallback is right for the user and wrong for you — so pair it with a build-time check that enumerates what should exist. Anything that degrades quietly in production needs something loud in CI.

Typed accessors are a real upgrade over integer IDs. The generated Res object turns a class of runtime crash into a compile error. Worth noticing when it happens, because the same pattern — codegen replacing a string or integer lookup — is available in a lot of places.

A working case is the most useful evidence. French resolving correctly everywhere disproved the broad theory immediately and pointed at the narrow one. When something fails on one platform, find the nearest thing that succeeds there — the difference between them is the bug.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

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

Follow by RSS