A Few Low-Density Phones Lost Their Bundled Wallpaper — The drawable vs nodpi Boundary in Play's Density Splits
App Bundle density splits will happily split images that should never be split, dropping a static resource on one density bucket only. Here is how I reproduced it with bundletool and fixed it by moving to drawable-nodpi or disabling density split — with the decision criteria.
While rolling an update out at 5%, a handful of reviews said the sample wallpapers "just show up gray, never load." I couldn't reproduce it on my Pixel or on a mid-tier test device. Crashlytics showed nothing. The one thing the reporters had in common: every device was a low-density budget phone. This was not a crash — a static image resource simply "did not exist" for one group of devices.
It took me a detour to find the cause. I blamed the CDN first, then resource shrinking from obfuscation, and finally landed on App Bundle density splits. As an indie developer who has run wallpaper apps for years, I thought I understood density splitting — but I had never run head-first into its real trap: it will split images that should never be split. This article walks from reproduction to fix, and shows exactly how far I let an Antigravity agent take the change, with the reasoning behind each call.
Why an image disappears "only on certain devices"
An Android App Bundle produces device-optimized split APKs from a single .aab. The split axes are ABI, language, and screen density. A device downloads only the splits matching its configuration: a low-density phone gets the low-density split, a high-density phone gets the high-density one, and resources for irrelevant densities never arrive. That is what keeps install size small.
The catch is how an image placed bare in res/drawable/ is treated. An unqualified drawable/ folder is treated internally as mdpi-equivalent, so with density splitting on it gets routed into the "medium-density split." A device whose density bucket does not pull that split can end up with no actual file behind R.drawable.sample_wallpaper. getDrawable() will sometimes substitute the nearest density, but when the target resource is in none of the density splits the device received, resolution fails outright. In my case the trigger was a single full-size sample wallpaper sitting in drawable/.
Resource location
Density-split handling
Reaches every device?
drawable/ (unqualified)
Bundled into the mdpi split
No (mostly mid-density devices)
drawable-xxhdpi/ etc.
Bundled into each density split
No (only that density)
drawable-nodpi/
Excluded from density split (base split)
Yes (all devices)
drawable-anydpi/ (vectors)
Excluded from density split
Yes (all devices)
Reproduce it with bundletool first
Fixing on a guess just moves the bug to another device. Before touching anything, I confirmed with my own eyes which density split holds what. With bundletool you specify a device spec and pull exactly the APK set that device would download.
# 1) Use the same .aab you released and write a device spec (JSON).# Set a low screenDensity to mimic a low-density phone.cat > device-ldpi.json << 'JSON'{ "supportedAbis": ["arm64-v8a"], "supportedLocales": ["en-US"], "screenDensity": 240, "sdkVersion": 26}JSON# 2) Generate only the APK set this device receives.bundletool build-apks \ --bundle=app-release.aab \ --output=ldpi.apks \ --device-spec=device-ldpi.json# 3) Unpack the set and check whether the image is present.unzip -o ldpi.apks -d ldpi_outfor apk in ldpi_out/splits/*.apk; do echo "== $apk ==" unzip -l "$apk" | grep -i "sample_wallpaper" || echo " (not present)"done
Run it three times with screenDensity at 240 / 320 / 480 and you'll see sample_wallpaper appear in no split for one of those densities. The moment a vague "missing on some devices" turned into a concrete "absent from this density split," the fix chose itself.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Reproduce the 'image missing on certain phones' symptom yourself by pulling device-specific APK sets with bundletool
✦Tell apart images that should be split by density from images every device must receive, and choose between drawable-nodpi and disabling density split with confidence
✦Hand the raster-asset scan and move to an Antigravity agent while you keep the density judgment, and port that split of labor into your own repo
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This, I think, is the heart of the problem. Density splitting itself is correct: icons and button backgrounds genuinely benefit from per-density variants. The mistake is putting an image that has no business being split onto the density axis at all.
A sample wallpaper is a full-size raster meant to reach every device as the same single file, regardless of density. Placing it in drawable/ drags it into a density split it never needed, and one bucket of devices loses it.
In the end the placement came down to one question: does this asset have any reason to ride the density axis? Icons do. Wallpapers do not. Putting it on an axis it had no business being on was the original design error.
Why it never reproduced on my Pixel turns out to be just as plain. Every device I test on happens to sit in a density bucket that receives the drawable/ (mdpi-equivalent) split. With this class of bug, "it doesn't reproduce on my phone" is part of the symptom, not evidence against it. The devices that don't reproduce it usually have more in common than the ones that do.
Fix 1: move it to drawable-nodpi
When only a few full-size images are involved, the lowest-side-effect fix is moving them to drawable-nodpi/. The nodpi qualifier declares "density-independent," removing the asset from density splitting and placing it in the base split that every device receives.
# Before: res/drawable/sample_wallpaper.webp (ends up in the mdpi split)# After : res/drawable-nodpi/sample_wallpaper.webp (reaches every device)mkdir -p app/src/main/res/drawable-nodpigit mv app/src/main/res/drawable/sample_wallpaper.webp \ app/src/main/res/drawable-nodpi/sample_wallpaper.webp
The resource ID stays R.drawable.sample_wallpaper, so not a single line of calling code changes — the folder qualifier differs, but the resource name does not. After moving, run bundletool again and confirm the image lands in the base split across the 240 / 320 / 480 device specs.
// Don't swallow resolution failures — log them so staged rollout reveals them.val drawable = runCatching { ContextCompat.getDrawable(context, R.drawable.sample_wallpaper) } .onFailure { Log.w("AssetGuard", "cannot resolve sample_wallpaper: density=${resources.displayMetrics.densityDpi}", it) } .getOrNull()// Whether any device logs a null here tells you if the fix actually took.
Fix 2: disable density split for the whole app
If you have many full-size rasters and folder moves can't keep up, you can disable density splitting itself, toggled in Gradle's bundle block.
// build.gradle.kts (app)android { bundle { density { // Bundle all density resources into the base split. // Reaches every device for sure, but download size grows. enableSplit = false } }}
This applies app-wide, so even icons ship every density to every device and install size balloons. For my wallpaper app I chose "nodpi for the affected images, density split left on." Here is the decision guide.
Situation
Recommended
Trade-off
A few full-size images that must reach all devices
Move to drawable-nodpi
Almost none (smallest change)
Many density-independent large images
Consolidate them into nodpi
Base split grows a little
Few per-density assets, want simpler management
enableSplit = false
Larger downloads
Icons, 9-patches where per-density is correct
Keep in density folders
No change needed
What I gave the Antigravity agent, and what I kept
The fix is simple, but eyeballing which rasters scattered across res/ are "full-size images that should be density-independent" is tedious. I handed that to an Antigravity agent: scan everything under drawable*, list the full-size rasters that are not vectors (.xml), 9-patches (.9.png), or state lists, and propose move diffs into drawable-nodpi/. Confirming that no call sites need rewriting — because the resource name is unchanged — is also faster as an agent-wide search.
What I kept was the semantic call: may this image be made density-independent? App icons and toolbar icons are correctly per-density, and pushing them to nodpi makes them look coarse. So I first gave the agent a "spec of forbidden actions" — exclude categories that need per-density variants (icons, small UI parts) from the move candidates. The agent is strong at mechanical scanning and diff generation; drawing the line of meaning is the human's job. Deciding that split of labor up front keeps you from trusting proposals blindly. I cover the negative-spec approach in detail in designing explicit forbidden actions for agents.
nodpi is not unconditionally safe — it also disables scaling
drawable-nodpi/ is not a magic "reaches everyone" folder. It carries a second meaning: the asset is never scaled to the device density.
An image in drawable/ is treated as mdpi-based and decoded into a bitmap scaled by densityDpi / 160. On an xxhdpi device (480dpi) that is 3×. A 1080×1920 wallpaper sitting in drawable/ was actually being allocated as roughly 3240×5760 — about 9× the memory. I stumbled on this while chasing the missing-asset bug: moving to nodpi had quietly stopped that pointless upscale as well.
// A nodpi bitmap is not upscaled to the device density.// For a wallpaper you want at native size, that is the correct behavior.val opts = BitmapFactory.Options().apply { inScaled = false // force native pixels even when decoding from drawable/}val bmp = BitmapFactory.decodeResource(resources, R.drawable.sample_wallpaper, opts)Log.d("AssetGuard", "decoded=${bmp.width}x${bmp.height} density=${resources.displayMetrics.densityDpi}")// Expected: the source pixel dimensions, identical on every device density
Setting inScaled = false explicitly isn't only about verifying behavior. It is insurance: if someone — or some agent — moves this image back into drawable/ a year from now, at least the memory profile won't silently change.
The flip side is that pushing UI assets into nodpi makes them physically smaller on high-density screens. An icon you intended as 48dp renders like 16dp on a 480dpi phone. That is the mine you step on when you conclude "everything reaches every device, so let's put it all in nodpi."
Asset type
Put it in nodpi?
Why
Full-size wallpapers, photos, large backgrounds
Yes
Not scaling is correct — and it cuts memory
Splash logo (already a vector)
Prefer anydpi-v24
Vectors are density-independent and scale cleanly
Toolbar and tab icons
No
They must match dp sizing, which requires scaling
9-patches (button backgrounds)
No
Stretch regions are interpreted against density
Stop the regression in CI
What worries me isn't the fix. It's that six months from now somebody — quite possibly me — drops one image back into drawable/. This bug never crashes, passes every unit test, and doesn't reproduce on my devices. So the check belongs somewhere a machine is watching.
#!/usr/bin/env bash# tools/verify-base-assets.sh — assert required assets live in the base splitset -euo pipefailAAB="${1:?usage: verify-base-assets.sh <path-to-aab>}"DENSITIES="240 320 480 560"# Assets that must reach every device. This list is maintained by a human.REQUIRED=(sample_wallpaper onboarding_hero share_frame)fail=0for d in $DENSITIES; do cat > /tmp/spec.json << JSON{"supportedAbis":["arm64-v8a"],"supportedLocales":["en-US"],"screenDensity":$d,"sdkVersion":26}JSON # Without --overwrite the second run fails outright bundletool build-apks --bundle="$AAB" --output="/tmp/set_$d.apks" \ --device-spec=/tmp/spec.json --overwrite > /dev/null rm -rf "/tmp/x_$d" && unzip -oq "/tmp/set_$d.apks" -d "/tmp/x_$d" base="$(find "/tmp/x_$d" -name 'base-master*.apk' | head -1)" if [ -z "$base" ]; then echo "NG density=$d : no base split found"; fail=1; continue; fi for name in "${REQUIRED[@]}"; do if ! unzip -l "$base" | grep -q "res/.*${name}"; then echo "NG density=$d : ${name} missing from the base split" fail=1 fi donedone[ "$fail" -eq 0 ] && echo "OK: every required asset is in the base split at all densities"exit "$fail"
One thing is deliberate here: REQUIRED is not generated automatically. Which assets must reach every device is a specification, not something to infer from the current state of res/. Generate it, and an image accidentally moved back into drawable/ quietly drops off the list as "not required." Never let the machine decide what it is supposed to protect — the same line I draw when handing a scan to an agent.
In CI this runs immediately after the release .aab job and blocks the release on exit 1. Locally it's one line: ./tools/verify-base-assets.sh app/build/outputs/bundle/release/app-release.aab. It takes about thirty seconds, which is cheap next to rolling back a staged release.
Verification and staged rollout
Once fixed, run bundletool across several device specs — including the density that broke — and mechanically confirm the image is in the base split.
for d in 240 320 480 560; do sed "s/\"screenDensity\": [0-9]*/\"screenDensity\": $d/" device-ldpi.json > /tmp/dev.json bundletool build-apks --bundle=app-release.aab --output=/tmp/out_$d.apks \ --device-spec=/tmp/dev.json >/dev/null 2>&1 unzip -o /tmp/out_$d.apks -d /tmp/o_$d >/dev/null if unzip -l /tmp/o_$d/splits/base-master.apk | grep -qi "sample_wallpaper"; then echo "density=$d : OK (in base)" else echo "density=$d : NG (still split)" fidone
Then don't jump to 100%. I roll wallpaper updates 5% → 25% → 50% → 100%, and at each step I check Crash-free users at 99.7% or above plus zero "couldn't resolve the image" entries in that custom log before moving on. A defect that never surfaces as a crash is invisible unless you place your own observation point. I write from the same stance about adding observation points in the coreLibraryDesugaring blind spot where a library crashes only on old devices.
Start by running bundletool build-apks --device-spec once against your own release .aab and peek at whether base-master.apk contains the images you assume reach every device. If they're missing, that is your proof they got swept into a density split.
If this spares even one other developer the hours I lost, I'll be glad. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.