The morning after the review notification landed, I dragged the Play Console rollout slider to 5%. Beautiful 4K/HDR Wallpapers v2.1.0 (versionCode 49) and Ukiyo-e Wallpapers v1.8.0 (versionCode 41). From the moment you click, the Crashlytics tab stays open.
Some weeks I spend more hours watching numbers after a release than writing the features that went into it. Confirming that nothing is broken has no natural end point, and nobody notices when you do it well. Running several apps in parallel as a solo developer, that quiet work eats the day.
Here is what actually broke in the two weeks after rollout, and how far I now hand the morning monitoring to Claude in Chrome. Only things I reproduced on a real device and shipped a fix for.
The first 48 hours are the dangerous ones
My standard Android rollout schedule is 5% → 25% → 50% → 100%, with each step running for 24 to 48 hours. The most nerve-wracking window is the first 48 hours at 5%. If Crashlytics's Crash-free users metric dips below 99.7%, or ANR pushes past 0.20%, I halt the rollout immediately by setting the share to 0%.
I halted twice during the v2.0.0 rollout. WallpaperPagerAdapter and ThumbnailViewPagerAdapter were throwing IndexOutOfBoundsException during scroll, and in 28 days the crash hit 50+ users across 56+ events.
The root cause was holding a direct reference to the list. The RecyclerView was reading the data source while it was being updated, hitting a race. One line fixed it: copy defensively in setItems.
// Before: shared reference
public void setItems(List<Wallpaper> list) {
this.mList = list;
notifyDataSetChanged();
}
// After: defensive copy
public void setItems(List<Wallpaper> list) {
this.mList = new ArrayList<>(list);
notifyDataSetChanged();
}After a long stretch of maintaining these apps, the conclusion I am left with is unromantic: RecyclerView crashes almost always trace back to a shared list reference. The exotic causes rarely show up. A caller hands you a list, then later calls clear() on it and refills it — that alone is enough to pull the rug out from under an adapter mid-scroll.
Halting a rollout is not the same as rolling it back
The first time I halted, I assumed I had undone the release. I had not. Setting the rollout share to 0% does not move devices that already received v2.1.0 back to the previous version. All a halt does is stop the release from spreading further.
So on an app with a hundred thousand users, halting at the 5% stage still leaves several thousand people holding the broken build. Misread that, and the hours you spend feeling relieved after the halt are the same hours your review page fills up. That misunderstanding cost me half a day of response time during the v2.0.0 rollout.
What follows a halt, then, is not a rollback — it is shipping a fix with a higher versionCode as fast as possible. I keep the thresholds fixed rather than deciding case by case, because a version of me at 2 a.m. will always argue for the lenient reading.
| Signal | Halt threshold | What comes next |
|---|---|---|
| Crash-free users | Below 99.7% | Drop the share to 0% and prepare a hotfix versionCode the same day |
| ANR rate | Above 0.20% | Hold the share, read the Play Console ANR clusters first (false positives are common) |
| Users hit by a single crash | More than 10 while at 5% | Halt. If the stack is identical, pin the repro before fixing |
| Crash within 3 seconds of launch | Any new occurrence | Halt immediately. Early crashes hurt the most per user |
That last row is deliberately stricter than the rest. An app that dies on launch is, from the user's side, simply an app that does not open, and it gets uninstalled regardless of how small the underlying bug is. The Glide desugaring miss below was exactly that category.
minSdk 23 and Glide 5.0.5's hard requirement on desugaring
Right after v2.0.0 shipped, Crashlytics flagged that every single Android 6.0.1 (API 23) user was crashing within three seconds of launch. 12 events, 4 users over 7 days, all tagged "early crash."
Glide 5.0.5 uses java.util.function.Supplier internally, and at minSdk 23 desugaring is mandatory. My build.gradle had the dependency coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' but I had not flipped the coreLibraryDesugaringEnabled true flag in compileOptions.
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled true // <-- I had missed this
}
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}When a library's dependency tree requires Java 8 APIs, adding the desugar dependency is not enough; you have to enable the compileOptions flag too. A small, dull lesson worth writing down.
A resource that vanished under Play Store density split
Some Google Pixel users on Android 12 hit Resources$NotFoundException at startup. The crash report pointed at MainActivity.onCreate:120, where I referenced the background_silver drawable.
The cause was placing background_silver.jpg only in drawable-xxhdpi/ and drawable-xxxhdpi/. When the Android App Bundle gets density-split by Play Store, the APK delivered to lower-density buckets did not contain the resource.
The fix is to also drop the file into drawable-nodpi/. drawable-nodpi/ is included in every density split APK, so it is the safe location for resources that must exist.
app/src/main/res/
├── drawable-xxhdpi/background_silver.jpg
├── drawable-xxxhdpi/background_silver.jpg
└── drawable-nodpi/background_silver.jpg <-- reaches every density
In the App Bundle era, deciding between density-specific directories and nodpi tends to fall between design and ops. I now keep a simple rule: critical resources go in nodpi, quality-sensitive ones go in density-specific buckets. The class of bug went away.
What I now hand off to Claude in Chrome for Crashlytics
The first 48 hours of a rollout need human eyes. Past that window, I hand off first-pass crash triage to Claude in Chrome every morning.
The routine is straightforward. Open the Firebase Console Crashlytics tab and sort yesterday's new crashes by these criteria:
- Crashes that pushed Crash-free users below 99.7%
- Crashes affecting more than 10 users
- Status "Active" and "Untouched"
After Claude in Chrome lays the list out, I decide which crashes actually need a response. The stack trace reading I delegate; the decision to write a patch is mine, based on blast radius and fix cost.
The prompt I send every morning has settled into this shape. Keep the instruction short and it quietly inherits whatever filters the Console had last, so I make it state them back to me first.
Open Crashlytics in the Firebase Console.
App: Beautiful 4K/HDR Wallpapers. Window: last 24 hours.
1. First, read back the filters currently applied on screen
(time range, version, status). If they differ from the above,
correct them before continuing.
2. List only new crashes with Status = Active and Untouched,
ordered by number of affected users.
3. For each one, give me a table with exactly four columns:
affected users / event count / top stack frame /
device and OS skew.
4. Do not speculate about root cause and do not propose fixes.Four columns in step 3, because anything longer and I stop reading it. Step 4 forbids root-cause analysis on purpose: a plausible-sounding guess is exactly the thing that talks me out of reading the stack trace myself. Triage wants raw observations, not interpretation.
The gotcha is that Firebase Console redesigns its UI periodically, which is why step 1 — reading the active filters back — cannot be dropped. Without it, the time range sometimes stays on "last 7 days" and known crashes reappear as new every morning. I chased the same issue twice before adding that single line.
Synthesizing ad-free state from multiple stores
The most delicate piece in post-v2.0.0 maintenance has been the ad-free state. Globals.SHOW_AD is the synthesis of two independent persistent stores:
BillingManager— lifetime IAP and subscriptionsAdFreeManager— time-limited ad-free earned by watching rewarded ads
If anywhere in the code calls setAdFree(false) directly, users who are ad-free via BillingManager get accidentally flipped back to ad-supported. The rule is to always route through the synthesis check (isAdFree || isRewardAdFree).
Earlier there was a logic-inversion bug in BillingManager.restorePurchases() where, if the user was ad-free but the subscription had expired, no path existed to flip back to false. I rebuilt that path with a finalEvaluation pattern: run both inapp and subs queries in parallel, wait for both, then synthesize. I also moved restorePurchases() to Activity.onResume() so an expiration during a long session is detected on the next resume.
When you touch billing as an indie developer, the shortest path is to admit upfront that "single source of truth" has multiple sources, and design the synthesis layer explicitly.
User settings vs. API defaults
Server-side defaults (shuffle, personalized-ads on/off) returned at app launch will conflict with what the user changed in the Settings screen. I settled on a *_USER_OVERRIDE flag per setting, stored under a separate key.
- Saves originating from user action go through a dedicated
*ByUser()method, which sets the override at the same time - Saves originating from the system skip the write if the override is set
This way the server can change its defaults without overwriting user choices, and clearing the override is enough to fall back to the API default. It is not glamorous, but it makes the precedence rules debuggable.
Rate-limited multilingual review replies
The other surprisingly heavy maintenance task is user review replies. I once handled 72 review replies in a single session across four apps and six major countries on the App Store alone. There are traps.
- Sending more than 30–40 in a single session risks Google's spam detection
- The App Store has rate limits; about 8 seconds between sends keeps things safe
- Pasting machine translation can be flagged as policy violation, so I write in a human-toned voice
- Affiliate links, cross-app promotion, and social links are not allowed
I currently reply in 11 languages: Japanese, English, Traditional Chinese, Italian, Russian, Korean, Persian, Ukrainian, Thai, Polish, and Brazilian Portuguese. Claude in Chrome handles the translation in a human-toned voice, and the prompt explicitly enforces an eight-second delay between sends.
Maintenance is three layers: monitor, triage, decide
Stepping back across v2.0.0 to v2.1.0, the loop reduces to three layers:
- Monitor — Check Crashlytics and Play Console quality metrics every morning. Most of it goes to Claude in Chrome.
- First-pass triage — Read stack traces of new crashes, name the blast radius and the pattern. A mix of AI and human.
- Fix decision — Look at impact and fix cost, then choose: patch, halt the rollout, or ignore. Human.
Running six apps as an indie developer, doing the monitoring layer by hand does not fit into the day. Hand the mechanical parts to AI and protect the cognitive bandwidth for layer 3. That has been the working trade-off.
Starting tomorrow, the AdMob mediation expansion (Liftoff, Unity Ads, InMobi) and the iOS port of the slideshow feature start running in parallel. I plan to hand v2.1.0 maintenance off to AI and pour the focus into the new work for the week.