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:
| # | Bug | Where it hid | How it was found | Symptom |
|---|---|---|---|---|
| 1 | Crash before unlock | Direct Boot window, any version | One-star review + crash report | Crash |
| 2 | String template matched nothing | API 24–28 only | An audit of something else | Empty list, silent |
| 3 | Invisible status bar icons | Android 15, dark mode | Manual testing on a new OS | Rendering |
| 4 | Swipe dragged the sheet | Any version, layout-dependent | Manual testing | Gesture |
| 5 | Plan switch billed twice | Any version, subscribers only | Billing audit | Money |
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
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.
| Written | Meant | |
|---|---|---|
| Source | "%/$Constants.DIRECTORY_NAME/%" | "%/${Constants.DIRECTORY_NAME}/%" |
| Interpolated | $Constants — the object | Constants.DIRECTORY_NAME — the value |
| Sent to MediaStore | %/utils.Constants@3f2a1b.DIRECTORY_NAME/% | %/Demixr/% |
| Rows returned | 0 | All of them |
| Compiler | Happy | Happy |
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.
| Android | setStatusBarColor | systemUiVisibility | Result in dark mode |
|---|---|---|---|
| ≤ 10 | Works | Works | Correct |
| 11–14 | Deprecated, still works | Deprecated, still works | Correct |
| 15+ | Ignored | Ignored | Dark 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
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 handle | Yes | Yes |
| The tab bar | Yes | Yes |
| The list | No | No |
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:
| Scenario | Before | After |
|---|---|---|
| Monthly → Yearly | Two subscriptions, two charges | One subscription, prorated |
| Wants to cancel | No route from inside the app | Deep link to Play's management page |
| Lifetime while subscribed | Both keep running, silently | Explicit prompt to cancel |
| Purchase state at launch | Not reconciled | queryPurchases 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
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 earlier | Why it works |
|---|---|
| Reboot the device and launch before unlocking | The only way to be in the Direct Boot window on purpose. |
| Keep one device on each supported API level, not just the newest | Bug 2 lived on 24–28 while 29+ worked perfectly. |
| Run the whole app in dark mode on the newest preview | Bug 3 was a rendering bug in one colour scheme on one version. |
| Test scrolling at the ends of a long list, not just the middle | Overscroll is where nested-scroll handoff happens. |
| Buy, switch and cancel every plan on a test account | Bug 5 is invisible unless you own something first. |
| Read one-star reviews as bug reports | See 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.