Every Android developer has a device that lies to them. It's the one on the desk — recent, unlocked, English, one locale, one screen size, the same OEM skin every day. Code written on it works on it.

These five bugs all shipped. None of them reproduced on the machine they were written on. They came from crash reports, a store review, and one audit that went looking for something else entirely.

Here they are at a glance, because the pattern is more interesting than any one of them:

#BugWhere it hidHow it was foundSymptom
1Crash before unlockDirect Boot window, any versionOne-star review + crash reportCrash
2String template matched nothingAPI 24–28 onlyAn audit of something elseEmpty list, silent
3Invisible status bar iconsAndroid 15, dark modeManual testing on a new OSRendering
4Swipe dragged the sheetAny version, layout-dependentManual testingGesture
5Plan switch billed twiceAny version, subscribers onlyBilling auditMoney

1. The app crashed if it started before the phone was unlocked

A one-star review said the app "sometimes crashes, sometimes downloads fine." A crash report from a Galaxy M53 said IllegalStateException at launch. Neither reproduced.

The cause is a corner of Android called Direct Boot. After a reboot, before the user types their PIN, the device is up but credential-encrypted storage is not available — and that's where default SharedPreferences live. If something starts your process in that window, and a push notification or a system broadcast will, any unconditional call to getSharedPreferences() throws.

sequenceDiagram
  participant S as System
  participant P as Your process
  participant CE as Credential-encrypted storage
  participant DE as Device-encrypted storage
  Note over S,DE: Device rebooted. User has not typed their PIN.
  S->>P: Broadcast wakes the process
  P->>P: BaseActivity.onCreate reads the theme
  P->>P: DI builds the preferences singleton
  P->>CE: getSharedPreferences()
  CE--xP: IllegalStateException
  Note over P: Crash. Repeats on every launch until unlock.
  S->>P: (after fix) same broadcast
  P->>DE: createDeviceProtectedStorageContext()
  DE-->>P: Defaults
  Note over P: Starts with default settings instead of dying
The window is short, it happens after every reboot, and nothing about it exists on an unlocked test device.

Ours was well hidden. BaseActivity.onCreate() read the theme synchronously, which forced the DI container to construct the preferences singleton immediately, whose constructor called getSharedPreferences() with no guard. So the app crashed on every launch attempt until the next unlock — which is exactly what "sometimes crashes, sometimes downloads fine" looks like from the outside.

The fix is to catch that specific exception and fall back to device-protected storage, so startup degrades to default settings instead of dying:

fun prefs(context: Context): SharedPreferences = try {
    PreferenceManager.getDefaultSharedPreferences(context)
} catch (e: IllegalStateException) {
    // Direct Boot: credential-encrypted storage is not mounted yet.
    // Start on defaults rather than not starting at all.
    context.createDeviceProtectedStorageContext()
        .getSharedPreferences("boot_safe", Context.MODE_PRIVATE)
}

Once the device is unlocked — the normal case, the case you always test — nothing changes at all.

Worth checking: anything your DI graph builds eagerly at startup. Direct Boot turns "constructed a bit early" into "crashes until you unlock."

2. A string template that quietly matched nothing

This is the best bug of the five, because it is invisible and the compiler is content.

The pre-Android-10 gallery filter built a MediaStore query argument like this:

"%/$Constants.DIRECTORY_NAME/%"

Kotlin's string templates take $ followed by a bare identifier. So it interpolated $Constants — the object itself, via toString() — and treated .DIRECTORY_NAME as five literal characters. The query went out asking for paths containing something like utils.Constants@3f2a1b.DIRECTORY_NAME, matched no rows, and returned an empty list.

WrittenMeant
Source"%/$Constants.DIRECTORY_NAME/%""%/${Constants.DIRECTORY_NAME}/%"
Interpolated$Constants — the objectConstants.DIRECTORY_NAME — the value
Sent to MediaStore%/utils.Constants@3f2a1b.DIRECTORY_NAME/%%/Demixr/%
Rows returned0All of them
CompilerHappyHappy

No crash. No warning. On API 24–28 the gallery filter silently showed nothing, in three places — videos, images and audio — while the Android 10+ branch, which is what everyone on the team was testing on, worked perfectly.

"%/${Constants.DIRECTORY_NAME}/%"

Two braces. The reason it survived so long is that every symptom pointed at MediaStore permissions or scoped storage, which is where you look when a gallery is empty on old Android.

It surfaced during an audit of something unrelated — whether two flavours of the app could be installed side by side without colliding. That audit came back clean on its actual question: all nine provider authorities carry ${applicationId}, no custom permissions, no shared process names, and the directory name, database name, channel ids and notification ids are already per-flavour. It just happened to walk past this on the way.

3. The status bar icons disappeared on Android 15

Dark mode, Android 15, open the bottom drawer: the clock and battery icons vanish.

Window.setStatusBarColor and decorView.systemUiVisibility are deprecated, and from Android 15 they are ignored — edge-to-edge is enforced and those APIs no longer do anything. Our drawer used them to force dark status bar icons unconditionally, which had been fine when the bar behind them was light. On 15, the bar went dark, the request to change the icons was dropped on the floor, and dark icons sat on a dark background.

AndroidsetStatusBarColorsystemUiVisibilityResult in dark mode
≤ 10WorksWorksCorrect
11–14Deprecated, still worksDeprecated, still worksCorrect
15+IgnoredIgnoredDark icons on a dark bar

The replacement is WindowCompat.setDecorFitsSystemWindows and WindowInsetsControllerCompat, choosing light or dark bar icons from the current theme rather than hardcoding either:

WindowCompat.setDecorFitsSystemWindows(window, false)

val isLightBackground = !resources.isNightMode()
WindowInsetsControllerCompat(window, window.decorView)
    .isAppearanceLightStatusBars = isLightBackground

The transparent status bar itself now comes from the pre-15 themes in values-v21, v23 and v27, since the Window API it used to come from has no effect any more.

The general shape: a deprecated API that silently stops working is worse than one that's removed. Removal is a build error. This was a rendering bug on one OS version in one colour scheme.

4. Swiping the list moved the sheet instead

The gallery lives in a bottom sheet. Swiping the list inside it dragged the whole sheet up and down instead of scrolling.

There were two separate causes, and fixing the first exposed the second.

flowchart TD
  T["Finger swipes the list"] --> A{"Does the list have<br/>anywhere to scroll?"}
  A -->|"No — wrap_content,<br/>no bounded viewport"| B["Touch falls through<br/>to the sheet"]
  A -->|"Yes"| C["List scrolls"]
  C --> D{"Did the swipe run past<br/>the top or bottom?"}
  D -->|"Yes"| E["Leftover scroll handed<br/>to the sheet via SwipeRefreshLayout"]
  D -->|"No"| F["Correct behaviour"]
  B --> G["Sheet drags. Bug."]
  E --> G
Two routes to the same wrong outcome. Closing the first one made the second visible.

The list's RecyclerView and its wrapper were wrap_content inside the sheet's SwipeRefreshLayout. With no bounded viewport the list frequently had nothing to scroll — so every swipe fell through, untouched, to the sheet's drag handling. Sizing both to match_parent fixed short lists.

Long lists still misbehaved. Once a swipe ran past the top or bottom of the list, the leftover scroll was handed to the sheet through its nested-scroll cooperation with the SwipeRefreshLayout — so overscrolling a long list started dragging the sheet.

The fix is a HandleOnlyBottomSheetBehavior that closes both routes: it refuses to capture a drag that begins over the list, and refuses the list's wrapping SwipeRefreshLayout as a nested-scroll partner. The handle and the tab bar are exempt from both checks, so they still drag the sheet exactly as before.

Gesture starts on…Captures the drag?Accepts nested scroll?
The handleYesYes
The tab barYesYes
The listNoNo

A scrollable list inside a draggable container is one of the few places in Android UI where the default behaviour is a coin toss, and it depends on layout parameters three levels away from the gesture.

5. Switching plans billed people twice

Not a crash. The most expensive one here.

Every purchase — monthly, yearly, lifetime — went through the same plain launchBillingFlow call with no reference to anything the user already owned. Three consequences:

  • Switching monthly to yearly started a second, independent subscription. Play had no reason to treat it as a change, so the user was billed for both at once.
  • Nothing in the app pointed a subscriber at Play's subscription management page — which is the only place a subscription can be cancelled, because Play Billing has no client-side cancel call. If you don't link to it, your users cannot leave.
  • Buying lifetime while a subscription was live reconciled nothing. The subscription kept renewing beside the lifetime purchase that had made it redundant, with nothing telling the user to go and cancel.
flowchart LR
  subgraph Before
    A1["User on Monthly"] --> B1["Taps Yearly"]
    B1 --> C1["launchBillingFlow<br/>no purchase token"]
    C1 --> D1["Play sees a new sale"]
    D1 --> E1["Two live subscriptions<br/>Two charges"]
  end
  subgraph After
    A2["User on Monthly"] --> B2["Taps Yearly"]
    B2 --> C2["launchBillingFlow +<br/>SubscriptionUpdateParams"]
    C2 --> D2["Play sees a plan change"]
    D2 --> E2["One subscription<br/>Prorated"]
  end

The billing layer now tracks the active subscription's product id and purchase token, from both queryPurchases and fresh purchases, and passes that token when switching plans — so Play treats it as a real plan change rather than a new sale:

val params = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(productDetails)
    .apply {
        activeSubscription?.let { current ->
            setSubscriptionUpdateParams(
                BillingFlowParams.SubscriptionUpdateParams.newBuilder()
                    .setOldPurchaseToken(current.purchaseToken)
                    .setSubscriptionReplacementMode(
                        BillingFlowParams.SubscriptionUpdateParams
                            .ReplacementMode.CHARGE_PRORATED_PRICE
                    )
                    .build()
            )
        }
    }
    .build()

Buying lifetime over a live subscription now raises an event the purchase dialog turns into an explicit prompt. Side by side:

ScenarioBeforeAfter
Monthly → YearlyTwo subscriptions, two chargesOne subscription, prorated
Wants to cancelNo route from inside the appDeep link to Play's management page
Lifetime while subscribedBoth keep running, silentlyExplicit prompt to cancel
Purchase state at launchNot reconciledqueryPurchases on every start

Billing is the one subsystem where a silent bug takes money from people who liked your app enough to pay for it. It deserves more test time than it usually gets, and it is very hard to test on the device on your desk.

The platform changes underneath all of this

Three of the five are platform-version bugs, and they are not random: each one sits on a line Android drew in a particular release and then stopped enforcing gently.

timeline
  title The API changes these bugs sit on
  API 24 : Direct Boot introduced : Bug 1 becomes possible
  API 26 : TYPE_PHONE overlays closed
  API 28 : Last version using the legacy MediaStore path : Bug 2's blast radius ends here
  API 29 : Scoped storage : The branch everyone tested
  API 30 : setStatusBarColor deprecated : Still works, so nobody moved
  API 35 : Edge-to-edge enforced : Bug 3 appears, deprecated calls ignored
A deprecation that still works is a deadline nobody hears.

What these have in common

Not one of these was found by writing more unit tests. Four were found on hardware or OS versions we didn't have in front of us, and the fifth was found by an audit asking a different question.

They also fail in the quietest way available. An exception in a constructor before unlock. A string that interpolates the wrong half of itself. A deprecated call that stops doing anything. A gesture that goes to the wrong view. A purchase that succeeds twice. Nothing goes red. Somebody just has a worse time than they should, and most of them never tell you.

If you want to find these earlierWhy it works
Reboot the device and launch before unlockingThe only way to be in the Direct Boot window on purpose.
Keep one device on each supported API level, not just the newestBug 2 lived on 24–28 while 29+ worked perfectly.
Run the whole app in dark mode on the newest previewBug 3 was a rendering bug in one colour scheme on one version.
Test scrolling at the ends of a long list, not just the middleOverscroll is where nested-scroll handoff happens.
Buy, switch and cancel every plan on a test accountBug 5 is invisible unless you own something first.
Read one-star reviews as bug reportsSee below.

Which is the actual argument for reading your one-star reviews closely. "Sometimes crashes, sometimes downloads fine" was a precise and accurate bug report. It just needed someone to recognise it.