I have been shipping iOS and Android apps as an indie developer since 2014, and I keep a 50M-cumulative-download wallpaper lineup alive by myself. Beautiful 4K/HDR Wallpapers v2.1.0 (versionCode 49) and Ukiyo-e Wallpapers v1.8.0 (versionCode 41) just cleared Google Play review and are now in phased rollout. Two weeks in, I want to write down what post-release maintenance looks like in practice, and what I now hand off to Claude in Chrome.
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();
}Eight years of running this app and the conclusion is unromantic: RecyclerView crashes are almost always shared list references.
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 gotcha is that Firebase Console redesigns its UI periodically, so my prompt now always asks Claude to confirm "which filters are currently active." Without that confirmation, the filter sometimes silently drops and yesterday's crashes look like new ones.
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.