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. The placement should be decided by a single question: does this asset have any reason to ride the density axis? Mine did not, and that was the original design error.
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.
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.