RORK LABJP
PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teamsFREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuouslySHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or XcodeSIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browserNATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touchFUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder PaperlinePRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teamsFREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuouslySHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or XcodeSIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browserNATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touchFUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline
Articles/App Dev
App Dev/2026-05-29Intermediate

Two Weeks of Maintenance After v2.1.0 — Running Crashlytics Triage Through Claude in Chrome

Notes from operating Beautiful 4K/HDR Wallpapers v2.1.0 and Ukiyo-e Wallpapers v1.8.0 through phased rollout. Defensive RecyclerView copies, Glide desugaring, drawable-nodpi placement, and what I now hand off to Claude in Chrome every morning.

Android44Maintenance3Crashlytics12Phased RolloutRecyclerView3Glide2Indie Developer11Claude in Chrome4

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.

SignalHalt thresholdWhat comes next
Crash-free usersBelow 99.7%Drop the share to 0% and prepare a hotfix versionCode the same day
ANR rateAbove 0.20%Hold the share, read the Play Console ANR clusters first (false positives are common)
Users hit by a single crashMore than 10 while at 5%Halt. If the stack is identical, pin the repro before fixing
Crash within 3 seconds of launchAny new occurrenceHalt 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 subscriptions
  • AdFreeManager — 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:

  1. Monitor — Check Crashlytics and Play Console quality metrics every morning. Most of it goes to Claude in Chrome.
  2. First-pass triage — Read stack traces of new crashes, name the blast radius and the pattern. A mix of AI and human.
  3. 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.

Share

Thank You for Reading

Rork 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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-28
Adding Slideshow and Page Jump to My Android Wallpaper Apps
Implementation notes from adding slideshow, a page-jump slider, and an ad-free option to the Android editions of Beautiful 4K/HDR Wallpapers and Ukiyo-e Wallpapers.
App Dev2026-06-25
Crashes Only in the Release Build — Rescuing Classes R8 Stripped in Expo (Android)
I turned on R8 code shrinking to slim down an AAB, and one screen started crashing only in production. Here is how I traced the stripped class through mapping.txt and added keep rules via expo-build-properties.
App Dev2026-05-31
Fixing the 'Signed With the Wrong Key' Error When Uploading a Rork App to Google Play
Your Rork app builds fine but Google Play rejects the upload with 'signed with the wrong key'? Here's how to tell which signing key is involved and the exact steps to fix it for each build setup.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →