Resources & Configuration Explained

ANDROID › System

Two phones can be identical hardware except one is set to French and the other to English, or one is in dark theme and the other isn't, or one is a folding tablet and the other a compact phone. Android answers all of that with one mechanism: **alternative resources**. You provide multiple versions of the same string, drawable, or layout, each tagged for a configuration, and the system picks the one that fits the device it's actually running on.

res/values/strings.xml         // default
res/values-fr/strings.xml      // French locale
res/values-night/colors.xml    // dark theme
res/drawable-hdpi/icon.png     // hdpi density

Interviewers lean on this topic because it's easy to get subtly wrong: qualifiers have a required order, one qualifier (density) doesn't behave like the rest, and the plain res/values/ and res/drawable/ directories with no qualifier at all aren't just 'the basic version', they're the **mandatory fallback**. If a device's configuration matches nothing you shipped and there's no default to catch it, the lookup has nowhere left to go.

Qualifiers stack on one directory name, and the order they appear in is not a style choice, it's enforced. Android defines a fixed precedence table, roughly: MCC/MNC, locale, layout direction, smallestWidth, width, height, screen size, orientation, UI mode (which includes night mode), density, and platform version, and qualifiers must appear left to right in that order, separated by dashes.

res/drawable-night-hdpi/     // correct: night before density
res/drawable-hdpi-night/     // wrong order
res/values-night/            // dark theme overrides, the qualifier is literally 'night'
res/values-v31/              // applies on API 31 and above

Two qualifiers worth calling out by name: **night** is what selects dark-theme overrides, there's no 'dark' or 'uimode-night' spelling, and a platform version qualifier like **v31** matches any device at API 31 or higher, not exactly 31, it has nothing to do with your app's versionCode. You also can't nest qualifiers in subfolders, res/drawable/hdpi-night/ isn't something Android reads, everything has to live in a single top-level folder name. Get the order wrong and nothing crashes at build time, the directory just silently fails to match at runtime, which is a nasty bug to chase.

Given all of that, how does Android actually choose? It's systematic, not a fuzzy 'closest guess'. First, it throws out any directory whose qualifier **contradicts** the device, a -fr directory when the device is en-US is eliminated outright. Then it walks the precedence table top to bottom: at the first qualifier type where any surviving directory specifies a value, it keeps only the directories that specify it and drops the rest, then moves to the next qualifier down and repeats, until one directory remains. Because locale sits above night mode in that table, a locale match wins out over a night-mode match whenever both are in play.

res/drawable/          // default
res/drawable-en/       // matches en-US
res/drawable-fr/       // contradicts en-US, eliminated

For an en-US device, drawable-fr is gone immediately as a contradiction. Between what's left, the default and drawable-en, the more specific drawable-en wins; the unqualified default only wins when nothing more specific survives the walk.

Density breaks the rule you just learned. Every other qualifier can eliminate a directory by contradiction; density never does. Android keeps every density-qualified directory in the running, picks whichever bucket is numerically closest to the device's actual density, and scales that bitmap to fit, preferring to scale a larger asset down rather than stretch a smaller one up. If two buckets are equally close, the higher bucket wins the tie.

res/drawable-mdpi/     // 1x baseline
res/drawable-xxhdpi/   // picked and scaled down if the xhdpi bucket is missing

This is why one set of density buckets can cover phones from years apart: a missing exact match never throws a directory out of consideration, it just changes which bucket gets picked and how much scaling happens.

The standard density buckets follow a 3:4:6:8:12:16 ratio relative to the mdpi baseline: ldpi (~120dpi), mdpi (~160dpi, 1x), hdpi (~240dpi, 1.5x), xhdpi (~320dpi, 2x), xxhdpi (~480dpi, 3x), xxxhdpi (~640dpi, 4x). Two buckets sit outside that scaling system entirely: nodpi assets are never scaled no matter what the device density is, useful for something like a fixed-size watermark, and anydpi matches every density and outranks any density-specific bitmap, which is exactly why vector XML drawables live there.

res/drawable/               // raster fallback
res/drawable-anydpi-v24/    // vector; matches ALL densities, wins over any bitmap bucket

A vector in drawable-anydpi-v24 beats a bitmap sitting in drawable-xxhdpi on every device that meets the API floor, density stops being a factor at all once a directory declares anydpi.

Launcher icons get one more wrinkle: they conventionally live in res/mipmap-* directories instead of res/drawable-*, even though both support the same file types. The reason is what happens during packaging. When your build produces density-split APKs, or when resource shrinking strips unused assets, non-matching drawable-* densities can be removed to save space. mipmap-* resources are deliberately exempted from that, because a launcher is allowed to pick an icon from a *higher* density bucket than the device's own to keep it looking crisp when scaled, and it needs those higher buckets to still be there.

res/mipmap-mdpi/    ic_launcher.png   //  48x48
res/mipmap-xxhdpi/  ic_launcher.png   // 144x144

Put a launcher icon in drawable-xxhdpi on an app that ships an mdpi density-split APK, and that xxhdpi asset can simply be gone by the time the launcher goes looking for it.

Now the units. px is a physical pixel. dp, density-independent pixel, is anchored to a 160dpi baseline so it renders at a consistent physical size across different screens. The conversion is one formula:

px = dp * (dpi / 160)

On mdpi (160dpi, the baseline) 1dp equals 1px. On xhdpi (320dpi) 1dp equals 2px. sp behaves exactly like dp for density scaling, but on top of that it also scales with the user's system font-size accessibility setting, something dp completely ignores. That's the whole reason for the convention: use dp for margins, padding, and view sizes, and reserve sp strictly for text, so a user who bumps their font size in system settings actually sees your text respond.

For 'is this device tablet-class', two qualifiers look similar but behave very differently across rotation. swNNNdp, smallestWidth, measures the *shorter* of the screen's two dimensions in dp, a value that doesn't change when you rotate the device, so layout-sw600dp reliably targets '7-inch-plus tablet' in portrait or landscape alike. wNNNdp, available width, measures the *current* width, smaller in portrait and larger in landscape, so its match can flip mid-session as the user rotates.

res/layout-sw600dp/   // stable: min(width, height), same in every orientation
res/layout-w600dp/    // dynamic: current width, can change on rotation

For a one-time tablet-or-not decision, sw600dp is the right tool. Deprecated buckets like layout-large or layout-xlarge should be avoided in new code entirely, smallestWidth replaced them.

Window size classes are the modern answer to the same problem, but keyed off the app's actual **window** rather than the physical device, so they behave correctly in split-screen, freeform desktop windowing, and on foldables, cases a static sw600dp folder can't see. Width breaks into three classes: **Compact** below 600dp, **Medium** from 600 to 839dp, **Expanded** at 840dp and above. Height has its own, different breakpoints: **Compact** below 480dp, **Medium** from 480 to 899dp, **Expanded** at 900dp and above.

@Composable
fun MyApp() {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
    val widthClass = windowSizeClass.windowWidthSizeClass
    if (widthClass == WindowWidthSizeClass.EXPANDED) {
        TwoPaneLayout()
    } else {
        SinglePaneLayout()
    }
}

A tablet in split-screen at 700dp wide lands in Medium, not Expanded, and that's the point: the same physical tablet can shift size classes as its window gets resized, which a fixed resource qualifier simply cannot express.

One object ties everything in this lesson together at runtime: Configuration, read via resources.getConfiguration(). It's the live snapshot the resource-selection algorithm actually consults, locale, orientation, the night-mode flag inside uiMode, screenWidthDp and screenHeightDp, smallestScreenWidthDp, densityDpi, fontScale, all of it. In Compose, never capture a Configuration snapshot once in a top-level val, read it through LocalConfiguration.current so recomposition picks up a rotation or a font-scale change automatically:

@Composable
fun ScreenInfo() {
    val config = LocalConfiguration.current
    Text("Width: ${config.screenWidthDp}dp")
}

For layout branching specifically, reach for WindowSizeClass over raw Configuration fields, it's the API actually designed for that decision and it tracks the window rather than the physical screen. If an interviewer asks you to explain resource selection end to end: alternative directories are matched against Configuration by eliminating contradictions and walking a fixed precedence table, density is the one qualifier that's scaled instead of eliminated, dp and sp exist to keep physical size and accessibility consistent across that same density range, and window size classes are the current, window-aware layer on top of it all for adaptive layout.

Back to Resources & Configuration