After shipping v2.0.0 of my Android wallpaper app, Beautiful HD Wallpapers, I watched the same IndexOutOfBoundsException stack trace accumulate in Google Play Console — 50+ crashes over 28 days. When I asked Antigravity to help debug it, the first suggestion was wrong. Here's how we got to the real fix.
What the Crash Looked Like
The error appearing in Android Vitals was almost always the same:
java.lang.IndexOutOfBoundsException: Inconsistency detected.
Invalid item position 12(offset:12).state:13
The numbers varied slightly each time, but the pattern was consistent: at the moment RecyclerView tried to render, the number of items the adapter expected didn't match the actual list size.
Running a portfolio of apps with over 50 million cumulative downloads as a solo developer means checking Play Console every day. Before v2.0.0, I was seeing 1–2 crashes per day. After release, it jumped to 5–7, and Crash-free users dropped below 99.5% before I caught it.
Antigravity's First Suggestion Missed the Real Problem
I pasted the stack trace directly into Antigravity and asked what was causing it. The response suggested adding synchronized access:
// Antigravity's first suggestion — this doesn't actually fix it
fun updateList(newItems: List<WallpaperItem>) {
synchronized(this) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
}The reasoning was sound in principle — synchronization prevents race conditions. But it missed something fundamental: items was a reference to an external MutableList, not an internal copy.
The Root Cause: Holding a Reference to an External List
During the v2.0.0 refactor, I had changed the WallpaperAdapter constructor to accept a mutable list directly:
// Problematic code (v2.0.0)
class WallpaperAdapter(
private val items: MutableList<WallpaperItem> // external reference kept as-is
) : RecyclerView.Adapter<WallpaperAdapter.ViewHolder>() {
override fun getItemCount() = items.size
// ...
}On the Activity side, the adapter was wired up like this:
val wallpapers = viewModel.getWallpapers().toMutableList()
val adapter = WallpaperAdapter(wallpapers)
recyclerView.adapter = adapter
// Later, in another part of the code:
wallpapers.removeAt(0) // ← this triggers the crashBecause the adapter holds a reference to the same list object, when wallpapers.removeAt(0) is called outside the adapter, the list size changes without the adapter knowing. RecyclerView expects 13 items; it finds 12. Crash.
The problem wasn't missing synchronization — it was that the adapter had no ownership over its own data.
The Fix: Defensive Copy
The fix is a single line in the constructor:
// Fixed code (v2.1.0)
class WallpaperAdapter(
items: List<WallpaperItem> // changed to List, copy internally
) : RecyclerView.Adapter<WallpaperAdapter.ViewHolder>() {
private val items: MutableList<WallpaperItem> = items.toMutableList() // defensive copy
override fun getItemCount() = items.size
fun submitList(newItems: List<WallpaperItem>) {
items.clear()
items.addAll(newItems.toMutableList()) // copy on update too
notifyDataSetChanged()
}
// ...
}By calling items.toMutableList(), the adapter owns its own internal copy. No matter what happens to the list passed in from the outside, the adapter's state is unaffected.
After shipping this as v2.1.0 — staged rollout at 5% → 25% → 50% → 100%, monitoring Crash-free users at each step — the crashes dropped to near zero. Crash-free users held above 99.7%.
Why Antigravity Missed It — and How I Asked Better Questions
The first response was off because I only gave Antigravity a stack trace and asked "what's causing this?" Antigravity inferred thread contention from the trace and suggested synchronization — a reasonable guess without full context.
When I switched the approach:
Paste the full adapter constructor + update method code
→ Ask: "Where in this code could IndexOutOfBoundsException occur?"
Antigravity immediately identified it: "You're storing a reference to an external list. If something outside the adapter modifies that list without calling notify, the adapter's state diverges."
After more than 12 years of solo app development, one consistent lesson with AI coding tools is this: asking "where could this break?" with the actual code in view gets you further than asking "what does this error mean?" with only a stack trace. Structure over symptoms.
A Checklist for RecyclerView Crashes
If you're seeing inconsistency-detected crashes, check these:
- Is the adapter constructor storing a direct reference to a
MutableListpassed in from outside? - Is the list being modified externally between notify calls?
- Is any list modification happening on a background thread without the adapter's involvement?
- If using
DiffUtil, are the diff calculation and UI update steps properly separated?
For new projects, ListAdapter with DiffUtil is worth using from the start — it handles internal list management, making the defensive copy pattern unnecessary:
// More robust approach: ListAdapter + DiffUtil
class WallpaperAdapter : ListAdapter<WallpaperItem, WallpaperAdapter.ViewHolder>(
object : DiffUtil.ItemCallback<WallpaperItem>() {
override fun areItemsTheSame(oldItem: WallpaperItem, newItem: WallpaperItem) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: WallpaperItem, newItem: WallpaperItem) =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// ...
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position)) // ListAdapter manages the internal list safely
}
}Whether you go with a defensive copy or migrate to ListAdapter, the underlying principle is the same: the adapter should own its own state. Once I internalized that, this class of crash stopped appearing entirely in my apps.
If you're working through a similar issue, I hope this record saves you some time.