100 Android gotchas

The mistakes that come up again and again in Android code review and in interviews. Each one is the snippet people write, next to what it should be.

1. Array field in a data class

Gotcha

data class Packet(val bytes: ByteArray)

val a = Packet(byteArrayOf(1, 2))
val b = Packet(byteArrayOf(1, 2))
a == b   // false

Correct

data class Packet(val bytes: ByteArray) {
  override fun equals(other: Any?) =
    other is Packet && bytes.contentEquals(other.bytes)
  override fun hashCode() = bytes.contentHashCode()
}
// or just: data class Packet(val bytes: List<Byte>)

The generated equals() compares arrays by identity, so two identical payloads are never equal. More on kotlin

2. Mutable object used as a map or set key

Gotcha

data class User(var name: String)

val set = mutableSetOf(User("ann"))
set.first().name = "bea"
set.contains(User("bea"))  // false

Correct

data class User(val name: String)

val set = mutableSetOf(User("ann"))
// replace instead of mutate
set.remove(User("ann"))
set.add(User("bea"))

Mutating a field after insertion changes hashCode, so the entry is stranded in the wrong bucket. More on kotlin

3. Overriding equals() but not hashCode()

Gotcha

class Id(val value: String) {
  override fun equals(other: Any?) =
    other is Id && other.value == value
  // no hashCode()
}

Correct

class Id(val value: String) {
  override fun equals(other: Any?) =
    other is Id && other.value == value
  override fun hashCode() = value.hashCode()
}

Two equal objects land in different buckets, so every hash-based collection silently misbehaves. More on kotlin

4. Reaching for !! because a var will not smart-cast

Gotcha

class Screen {
  var user: User? = null
  fun render() {
    if (user != null) {
      show(user!!.name)   // NPE if cleared mid-flight
    }
  }
}

Correct

class Screen {
  var user: User? = null
  fun render() {
    val u = user ?: return   // snapshot once
    show(u.name)
  }
}

A mutable property can change between the check and the use, and !! turns that race into a crash. More on kotlin

5. Expecting extension functions to be polymorphic

Gotcha

open class Animal
class Dog : Animal()

fun Animal.speak() = "..."
fun Dog.speak() = "woof"

val a: Animal = Dog()
a.speak()   // "..." not "woof"

Correct

open class Animal { open fun speak() = "..." }
class Dog : Animal() { override fun speak() = "woof" }

val a: Animal = Dog()
a.speak()   // "woof"

Extensions resolve on the static type at the call site, so the subclass version never runs. More on kotlin

6. Using assert() to guard an invariant

Gotcha

fun setProgress(p: Int) {
  assert(p in 0..100)   // no-op on device
  field = p
}

Correct

fun setProgress(p: Int) {
  require(p in 0..100) { "progress out of range: " + p }
  field = p
}
// require -> IllegalArgumentException (bad input)
// check   -> IllegalStateException    (bad state)

JVM assertions are disabled by default on Android, so the check never runs in a real app. More on kotlin

7. Chaining map and filter over a large list

Gotcha

items                      // 50_000 rows
  .map { it.toDomain() }   // new list
  .filter { it.isActive }  // new list
  .take(20)                // new list

Correct

items.asSequence()
  .filter { it.isActive }  // cheapest test first
  .map { it.toDomain() }
  .take(20)
  .toList()                // one list, 20 items

Every operator allocates a whole intermediate list, so an N-step chain walks the data N times. More on kotlin

8. Removing from a list you are iterating

Gotcha

for (task in tasks) {
  if (task.done) tasks.remove(task)
}
// ConcurrentModificationException

Correct

tasks.removeAll { it.done }

// or, if you need the iterator:
val it = tasks.iterator()
while (it.hasNext()) if (it.next().done) it.remove()

The iterator notices the structural change and throws ConcurrentModificationException. More on kotlin

9. Trusting a List to be immutable

Gotcha

class Cart {
  private val _items = mutableListOf<Item>()
  val items: List<Item> get() = _items
}
// caller:
(cart.items as MutableList).clear()

Correct

class Cart {
  private val _items = mutableListOf<Item>()
  val items: List<Item> get() = _items.toList()
}
// caller gets a snapshot; the cast fails

List is a read-only view, not a copy, so the caller can cast it back and edit your state. More on kotlin

10. Treating a Java return value as non-null

Gotcha

// Java: String getTitle() { return null; }
val len = javaObj.title.length
// NPE, and the compiler never warned you

Correct

val len = javaObj.title?.length ?: 0

// better: annotate the Java side
// @Nullable String getTitle()

Unannotated Java types are platform types: Kotlin skips the null check and you get an NPE at the use site. More on kotlin

11. Companion val where you wanted a constant

Gotcha

class Api {
  companion object {
    val BASE = "https://x.dev"   // getter, not inlined
  }
}
// @Header(Api.BASE) -> compile error

Correct

class Api {
  companion object {
    const val BASE = "https://x.dev"
  }
}
// inlined at every call site, usable in annotations

A plain companion val is a getter call on a synthetic class, and it cannot be used in annotations. More on kotlin

12. when as a statement over a sealed type

Gotcha

sealed interface State
// later someone adds State.Error

fun render(s: State) {
  when (s) {
    is State.Loading -> spinner()
    is State.Data -> list(s.rows)
  }   // Error falls through, no warning
}

Correct

fun render(s: State) = when (s) {
  is State.Loading -> spinner()
  is State.Data -> list(s.rows)
}   // expression: adding Error breaks the build

As a statement, when does not have to be exhaustive, so a new subclass compiles and silently does nothing. More on kotlin

13. else branch on an exhaustive when

Gotcha

when (result) {
  is Ok -> show(result.data)
  else -> showError()   // new Retry case lands here
}

Correct

when (result) {
  is Ok -> show(result.data)
  is Failed -> showError()
  is Retry -> showRetry()   // added by the compiler's demand
}

else absorbs every future subclass, throwing away the one compiler check that makes sealed types worth using. More on kotlin

14. lateinit read before it is assigned

Gotcha

class Frag : Fragment() {
  private lateinit var adapter: Adapter
  override fun onCreate(b: Bundle?) {
    adapter.submit(emptyList())   // crash
  }
  override fun onViewCreated(...) { adapter = Adapter() }
}

Correct

class Frag : Fragment() {
  private val adapter by lazy { Adapter() }
  override fun onCreate(b: Bundle?) {
    adapter.submit(emptyList())   // built on first touch
  }
}

lateinit removes the null check but not the null, so an early read throws UninitializedPropertyAccessException. More on kotlin

15. Launching in GlobalScope

Gotcha

class UserViewModel : ViewModel() {
  fun load() {
    GlobalScope.launch {
      _state.value = repo.fetch()   // still running after onCleared
    }
  }
}

Correct

class UserViewModel : ViewModel() {
  fun load() {
    viewModelScope.launch {
      _state.value = repo.fetch()   // cancelled in onCleared
    }
  }
}

Nothing cancels it, so the job outlives the screen and keeps the ViewModel and its captures alive. More on coroutines

16. A hand-rolled CoroutineScope nobody cancels

Gotcha

class MyActivity : AppCompatActivity() {
  private val scope = CoroutineScope(Dispatchers.Main)
  override fun onCreate(b: Bundle?) {
    scope.launch { poll() }
  }
  // no cancel anywhere
}

Correct

class MyActivity : AppCompatActivity() {
  override fun onCreate(b: Bundle?) {
    lifecycleScope.launch { poll() }
  }
}
// if you must own one: override onDestroy { scope.cancel() }

A scope you construct is yours to cancel, and every rotation creates another one that never stops. More on coroutines

17. Blocking work inside a main-dispatched coroutine

Gotcha

viewModelScope.launch {
  val bytes = File(path).readBytes()   // main thread
  _state.value = decode(bytes)
}

Correct

viewModelScope.launch {
  val bytes = withContext(Dispatchers.IO) {
    File(path).readBytes()
  }
  _state.value = decode(bytes)
}

launch does not move you off the main thread, so a blocking call still freezes the frame. More on coroutines

18. catch (e: Exception) around suspending code

Gotcha

try {
  repo.sync()
} catch (e: Exception) {
  log(e)          // eats CancellationException
  retryLater()    // keeps running after cancel
}

Correct

try {
  repo.sync()
} catch (e: CancellationException) {
  throw e
} catch (e: Exception) {
  log(e); retryLater()
}

CancellationException is an Exception, so a blanket catch swallows cancellation and the coroutine refuses to stop. More on coroutines

19. runBlocking to call a suspend function

Gotcha

override fun onCreate(b: Bundle?) {
  val user = runBlocking { repo.user() }   // ANR
  render(user)
}

Correct

override fun onCreate(b: Bundle?) {
  lifecycleScope.launch {
    render(repo.user())
  }
}

runBlocking parks the calling thread, so on the main thread it is a self-inflicted ANR. More on coroutines

20. async followed straight by await

Gotcha

val user = async { api.user() }.await()
val feed = async { api.feed() }.await()
// 300ms + 300ms

Correct

coroutineScope {
  val user = async { api.user() }
  val feed = async { api.feed() }
  render(user.await(), feed.await())   // 300ms total
}

Awaiting each call before starting the next makes two parallel requests strictly sequential. More on coroutines

21. try/catch wrapped around an async block

Gotcha

try {
  val d = async { api.user() }   // throws -> cancels parent
  render(d.await())
} catch (e: IOException) {
  showError()   // never reached
}

Correct

supervisorScope {
  val d = async { api.user() }
  try {
    render(d.await())
  } catch (e: IOException) {
    showError()
  }
}

async reports failure to its parent job first, so the crash happens outside your catch. More on coroutines

22. Unhandled throw inside launch

Gotcha

viewModelScope.launch {
  _state.value = repo.fetch()   // IOException -> crash
}

Correct

viewModelScope.launch {
  _state.value = try {
    UiState.Data(repo.fetch())
  } catch (e: IOException) {
    UiState.Error(e.message)
  }
}

An exception in launch goes to the scope handler, and with no handler installed it takes the process down. More on coroutines

23. Dispatchers.IO for CPU-bound work

Gotcha

withContext(Dispatchers.IO) {
  bitmap.applyBlur(radius = 25)   // pure CPU
}

Correct

withContext(Dispatchers.Default) {
  bitmap.applyBlur(radius = 25)
}
// IO = blocking waits, Default = computation

IO is sized for waiting threads (64+), so CPU work there oversubscribes the cores and thrashes. More on coroutines

24. A loop that never checks for cancellation

Gotcha

viewModelScope.launch(Dispatchers.Default) {
  for (row in oneMillionRows) {
    heavy(row)   // no suspend point, cancel ignored
  }
}

Correct

viewModelScope.launch(Dispatchers.Default) {
  for (row in oneMillionRows) {
    ensureActive()   // or yield()
    heavy(row)
  }
}

Cancellation is cooperative: code with no suspension point keeps burning CPU after cancel() returns. More on coroutines

25. A suspend function that is not main-safe

Gotcha

suspend fun readConfig(): Config =
  file.readText().let(::parse)   // blocks the caller's thread

// every caller now needs withContext(IO)

Correct

suspend fun readConfig(): Config =
  withContext(Dispatchers.IO) {
    file.readText().let(::parse)
  }
// callers just call it

suspend is not a promise about threads, so the burden of withContext lands on every caller instead of once here. More on coroutines

26. SupervisorJob handed to launch

Gotcha

scope.launch(SupervisorJob()) {
  launch { a() }
  launch { b() }   // b fails -> a still cancelled
}

Correct

scope.launch {
  supervisorScope {
    launch { a() }
    launch { b() }   // b fails, a keeps going
  }
}

A job passed to launch becomes a child of the scope job, so the supervising behaviour is thrown away. More on coroutines

27. viewModelScope for work that must finish

Gotcha

fun submitOrder(o: Order) {
  viewModelScope.launch {
    api.submit(o)   // user backs out -> cancelled
  }
}

Correct

fun submitOrder(o: Order) {
  WorkManager.getInstance(ctx)
    .enqueue(OneTimeWorkRequestBuilder<SubmitWorker>()
      .setInputData(o.toData()).build())
}
// survives navigation and process death

Leaving the screen cancels the scope, so the write is lost halfway through. More on coroutines

28. Collecting a Flow without repeatOnLifecycle

Gotcha

override fun onCreate(b: Bundle?) {
  lifecycleScope.launch {
    vm.state.collect { render(it) }
  }
}   // still collecting while the app is backgrounded

Correct

override fun onCreate(b: Bundle?) {
  lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
      vm.state.collect { render(it) }
    }
  }
}

lifecycleScope only cancels at onDestroy, so the collector keeps running and updating a backgrounded UI. More on flow

29. stateIn(Eagerly) for screen state

Gotcha

val state = repo.observe()
  .stateIn(viewModelScope, SharingStarted.Eagerly, Empty)

Correct

val state = repo.observe()
  .stateIn(
    viewModelScope,
    SharingStarted.WhileSubscribed(5_000),   // rides out rotation
    Empty,
  )

Eagerly never stops the upstream, so a socket or location feed keeps draining battery with nobody watching. More on flow

30. One-off events modelled as StateFlow

Gotcha

private val _nav = MutableStateFlow<Route?>(null)
val nav = _nav.asStateFlow()
// rotate -> navigates a second time

Correct

private val _nav = Channel<Route>(Channel.BUFFERED)
val nav = _nav.receiveAsFlow()
// each event delivered exactly once

StateFlow replays its current value, so every rotation shows the same snackbar or fires the same navigation again. More on flow

31. Expecting StateFlow to re-emit an equal value

Gotcha

_state.value = UiState(count = 1)
_state.value = UiState(count = 1)   // no emission
// "retry" taps stop working

Correct

data class UiState(val count: Int, val nonce: Long = 0)

_state.update { it.copy(nonce = it.nonce + 1) }
// or keep events off the state object entirely

StateFlow conflates by equals(), so setting the same data class value again emits nothing. More on flow

32. withContext inside a flow builder

Gotcha

flow {
  withContext(Dispatchers.IO) {
    emit(db.load())   // IllegalStateException
  }
}

Correct

flow {
  emit(db.load())
}.flowOn(Dispatchers.IO)

A flow must emit in the context it was collected in, so this fails the context-preservation check at runtime. More on flow

33. Collecting a flow inside another collect

Gotcha

query.collect { q ->
  repo.search(q).collect { render(it) }
  // results from an old query still arrive
}

Correct

query
  .flatMapLatest { q -> repo.search(q) }
  .collect { render(it) }
// switching query cancels the previous search

The inner collector is never cancelled when the outer value changes, so old collectors pile up and race. More on flow

34. Expecting flowOn to affect the whole chain

Gotcha

repo.rows()
  .flowOn(Dispatchers.IO)
  .map { it.heavyParse() }   // runs on Main
  .collect { render(it) }

Correct

repo.rows()
  .map { it.heavyParse() }
  .flowOn(Dispatchers.IO)    // covers rows() and map
  .collect { render(it) }

flowOn only moves operators above it, so anything after it runs back on the collector context. More on flow

35. Two collectors on one cold flow

Gotcha

val prices = flow {
  while (true) { emit(api.prices()); delay(5_000) }
}
// two screens collect -> two polling loops

Correct

val prices = flow {
  while (true) { emit(api.prices()); delay(5_000) }
}.shareIn(scope, SharingStarted.WhileSubscribed(5_000), replay = 1)

A cold flow restarts its producer for every collector, so you pay for the same network call twice. More on flow

36. tryEmit on a SharedFlow with no buffer

Gotcha

private val _events = MutableSharedFlow<Event>()

fun send(e: Event) {
  _events.tryEmit(e)   // false while backgrounded
}

Correct

private val _events = MutableSharedFlow<Event>(
  replay = 0,
  extraBufferCapacity = 8,
  onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

With replay 0 and no extra buffer, tryEmit returns false when nobody is collecting and the event vanishes. More on flow

37. A slow collector on a fast flow

Gotcha

ticks.collect { tick ->
  renderExpensively(tick)   // 200ms
}
// emitter blocked, UI falls behind

Correct

ticks.collectLatest { tick ->
  renderExpensively(tick)   // cancelled on the next tick
}
// or .buffer() if you need every value

collect is sequential, so a slow body applies backpressure and stalls the producer. More on flow

38. An Activity parked in a static field

Gotcha

class MainActivity : AppCompatActivity() {
  companion object { var current: MainActivity? = null }
  override fun onCreate(b: Bundle?) { current = this }
}

Correct

class MainActivity : AppCompatActivity() {
  companion object { var current: WeakReference<MainActivity>? = null }
  override fun onCreate(b: Bundle?) { current = WeakReference(this) }
  override fun onDestroy() { current = null; super.onDestroy() }
}
// better still: don't reach for the Activity from elsewhere

The companion object outlives the Activity, so the whole view tree is pinned in memory forever. More on android

39. postDelayed with no matching removeCallbacks

Gotcha

handler.postDelayed({ hideBanner() }, 60_000)
// nothing cancels it on destroy

Correct

private val hide = Runnable { hideBanner() }

override fun onStart() { handler.postDelayed(hide, 60_000) }
override fun onStop() { handler.removeCallbacks(hide) }

The queued Runnable holds the Activity until it fires, so a 60s delay is a 60s leak. More on android

40. registerReceiver with no unregister

Gotcha

override fun onCreate(b: Bundle?) {
  registerReceiver(netReceiver, filter)
}
// no onDestroy

Correct

override fun onStart() { registerReceiver(netReceiver, filter) }
override fun onStop() { unregisterReceiver(netReceiver) }

// or skip the pairing entirely:
// ConnectivityManager.registerNetworkCallback in a lifecycle-aware wrapper

The system keeps the receiver and its enclosing Activity alive, and the next register throws on leak detection. More on android

41. A Fragment holding its ViewBinding past onDestroyView

Gotcha

class Frag : Fragment() {
  private lateinit var binding: FragBinding
  // never cleared -> leaks on every back-and-forward
}

Correct

class Frag : Fragment() {
  private var _binding: FragBinding? = null
  private val binding get() = _binding!!

  override fun onDestroyView() {
    _binding = null
    super.onDestroyView()
  }
}

A Fragment outlives its view, so a retained binding keeps the whole destroyed view hierarchy alive. More on android

42. Assuming a ViewModel survives process death

Gotcha

class SearchViewModel : ViewModel() {
  var query: String = ""   // gone after a low-memory kill
}

Correct

class SearchViewModel(
  private val handle: SavedStateHandle,
) : ViewModel() {
  var query: String
    get() = handle["query"] ?: ""
    set(v) { handle["query"] = v }
}

A ViewModel survives rotation, not a background kill, so the user comes back to a blank screen. More on android

43. An Activity Context handed to a singleton

Gotcha

object Analytics {
  lateinit var ctx: Context
}
// in onCreate:
Analytics.ctx = this   // the Activity

Correct

object Analytics {
  lateinit var ctx: Context
}
Analytics.ctx = applicationContext

// application scope -> application Context
// UI work (themes, dialogs, inflation) -> Activity Context

The singleton lives for the process, so it holds a destroyed Activity for the life of the app. More on android

44. PendingIntent with no mutability flag

Gotcha

PendingIntent.getActivity(
  ctx, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT,
)   // IllegalArgumentException on API 31+

Correct

PendingIntent.getActivity(
  ctx, 0, intent,
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// FLAG_MUTABLE only when the receiver must fill in extras

From Android 12 a PendingIntent must declare its mutability, and the app crashes on creation if it does not. More on android

45. Firing an implicit Intent without a handler check

Gotcha

startActivity(Intent(Intent.ACTION_VIEW, uri))
// ActivityNotFoundException on a device with no browser

Correct

runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri)) }
  .onFailure { showNoAppMessage() }

// plus a <queries> entry in the manifest for resolveActivity to work

If no app can handle it the call throws ActivityNotFoundException, and package visibility makes that common on API 30+. More on android

46. Using a dangerous permission you only declared

Gotcha

// manifest has ACCESS_FINE_LOCATION
val loc = fusedClient.lastLocation   // SecurityException

Correct

val granted = ContextCompat.checkSelfPermission(
  ctx, Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED

if (granted) use(fusedClient.lastLocation) else launcher.launch(perm)

A manifest entry is not a grant, and the user can revoke a permission between two launches. More on android

47. startService from the background

Gotcha

// from a BroadcastReceiver, app in background
context.startService(Intent(context, SyncService::class.java))
// IllegalStateException

Correct

WorkManager.getInstance(context).enqueue(
  OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(Constraints(requiredNetworkType = CONNECTED))
    .build(),
)

Since Android 8 a background app cannot start a background service, and the call throws IllegalStateException. More on android

48. Committing a transaction after onSaveInstanceState

Gotcha

api.load { result ->
  supportFragmentManager.beginTransaction()
    .replace(R.id.host, ResultFragment())
    .commit()   // IllegalStateException if backgrounded
}

Correct

lifecycleScope.launch {
  repeatOnLifecycle(Lifecycle.State.STARTED) {
    vm.result.collect { showResultFragment(it) }
  }
}
// only commits while the state is safe to change

The state is already written, so the framework refuses the change with IllegalStateException. More on android

49. Expecting finish() to stop the method

Gotcha

override fun onCreate(b: Bundle?) {
  if (!loggedIn) finish()
  setContentView(R.layout.main)
  loadProfile()   // still runs, often crashes
}

Correct

override fun onCreate(b: Bundle?) {
  if (!loggedIn) { finish(); return }
  setContentView(R.layout.main)
  loadProfile()
}

finish() only schedules the teardown, so the rest of the method runs on an Activity that is on its way out. More on android

50. mutableStateOf without remember

Gotcha

@Composable
fun Counter() {
  var n by mutableStateOf(0)   // new state each pass
  Button({ n++ }) { Text("$n") }   // always 0
}

Correct

@Composable
fun Counter() {
  var n by remember { mutableStateOf(0) }
  Button({ n++ }) { Text("$n") }
}

A fresh state object is created on every recomposition, so the value resets the moment anything changes. More on compose

51. remember with no key over a changing input

Gotcha

@Composable
fun Row(user: User) {
  val initials = remember { user.name.initials() }
  Text(initials)   // still the first user's initials
}

Correct

@Composable
fun Row(user: User) {
  val initials = remember(user.name) { user.name.initials() }
  Text(initials)
}

remember caches until it leaves composition, so the value goes stale when the input it was derived from changes. More on compose

52. remember where the state must survive rotation

Gotcha

var query by remember { mutableStateOf("") }
// rotate -> the search box is empty again

Correct

var query by rememberSaveable { mutableStateOf("") }
// survives rotation and process death

remember only survives recomposition, so a config change throws away what the user typed. More on compose

53. LaunchedEffect(Unit) over a changing parameter

Gotcha

@Composable
fun Detail(id: String, vm: VM) {
  LaunchedEffect(Unit) { vm.load(id) }
  // navigate to another id -> never reloads
}

Correct

@Composable
fun Detail(id: String, vm: VM) {
  LaunchedEffect(id) { vm.load(id) }
  // relaunches (and cancels the old load) per id
}

Unit never changes, so the effect runs once and keeps serving the first id forever. More on compose

54. Starting a coroutine in the composable body

Gotcha

@Composable
fun Screen(vm: VM) {
  vm.viewModelScope.launch { vm.refresh() }   // per recomposition
  ...
}

Correct

@Composable
fun Screen(vm: VM) {
  LaunchedEffect(Unit) { vm.refresh() }       // once per entry
  // for callbacks: val scope = rememberCoroutineScope()
}

The body runs on every recomposition, so you fire a new request each frame and none of them get cancelled. More on compose

55. Mutating a list held in mutableStateOf

Gotcha

val items = remember { mutableStateOf(mutableListOf<Item>()) }
items.value.add(item)   // no recomposition

Correct

val items = remember { mutableStateListOf<Item>() }
items.add(item)

// or keep it immutable:
// items.value = items.value + item

The state holds the same list reference, so Compose sees no change and never recomposes. More on compose

56. LazyColumn items with no key

Gotcha

LazyColumn {
  items(rows) { row -> Card(row) }
}
// delete row 0 -> row 1 inherits its expanded state

Correct

LazyColumn {
  items(rows, key = { it.id }) { row -> Card(row) }
}

Without a key, item state is bound to position, so a reorder or delete moves state onto the wrong row. More on compose

57. A scrolling Column inside a LazyColumn

Gotcha

LazyColumn {
  item {
    Column(Modifier.verticalScroll(rememberScrollState())) { ... }
  }
}   // IllegalStateException: infinite maximum height

Correct

LazyColumn {
  item { Header() }
  items(rows, key = { it.id }) { Row(it) }
  item { Footer() }
}
// one scroller, one list

A lazy list gives its children infinite height, and a child that also wants infinite height throws. More on compose

58. A side effect written straight into composition

Gotcha

@Composable
fun Screen() {
  analytics.log("screen_view")   // once per recomposition
  ...
}

Correct

@Composable
fun Screen() {
  LaunchedEffect(Unit) { analytics.log("screen_view") }
  ...
}

Composition can run any number of times and be thrown away, so the analytics event fires on every frame. More on compose

59. Deriving a boolean from scroll position directly

Gotcha

val showTop = listState.firstVisibleItemIndex > 0
// recomposes on every scroll frame

Correct

val showTop by remember {
  derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// recomposes only when the boolean flips

Reading the raw scroll value recomposes on every pixel, when the answer only changes once. More on compose

60. collectAsState in a screen composable

Gotcha

val state by vm.state.collectAsState()

Correct

val state by vm.state.collectAsStateWithLifecycle()
// stops at STOPPED, restarts at STARTED

It keeps collecting while the app is backgrounded, so the upstream flow never gets a chance to stop. More on compose

61. Registering a listener with no matching dispose

Gotcha

LaunchedEffect(Unit) {
  sensor.registerListener(cb)   // never unregistered
}

Correct

DisposableEffect(sensor) {
  sensor.registerListener(cb)
  onDispose { sensor.unregisterListener(cb) }
}

The composable can leave composition at any time, so an unregistered listener leaks and fires into nothing. More on compose

62. Modifier order treated as a set of properties

Gotcha

Box(
  Modifier
    .padding(16.dp)
    .background(Blue)   // blue does NOT cover the padding
    .clickable { }      // ripple misses the padding too
)

Correct

Box(
  Modifier
    .background(Blue)
    .clickable { }
    .padding(16.dp)     // inner padding, full-size touch target
)

Modifiers wrap left to right, so padding before background paints a smaller box than you expect. More on compose

63. Passing the ViewModel down into leaf composables

Gotcha

@Composable
fun UserCard(vm: UserViewModel) {
  Text(vm.state.value.name)
  Button({ vm.refresh() }) { Text("Refresh") }
}

Correct

@Composable
fun UserCard(name: String, onRefresh: () -> Unit) {
  Text(name)
  Button(onRefresh) { Text("Refresh") }
}
// the ViewModel stays at the screen root

A ViewModel is an unstable type, so it defeats skipping and makes every leaf untestable in isolation. More on compose

64. A View or Activity Context stored in a ViewModel

Gotcha

class ProfileViewModel(
  private val activity: Activity,
) : ViewModel() {
  fun title() = activity.getString(R.string.profile)
}

Correct

class ProfileViewModel(
  @ApplicationContext private val ctx: Context,
) : ViewModel() {
  fun title() = ctx.getString(R.string.profile)
}
// better: expose a resource id and let the UI resolve it

The ViewModel outlives the Activity by design, so it leaks the exact thing it was built to outlive. More on architecture

65. Exposing the MutableStateFlow itself

Gotcha

class VM : ViewModel() {
  val state = MutableStateFlow(UiState())
}
// fragment: vm.state.value = UiState(loading = true)

Correct

class VM : ViewModel() {
  private val _state = MutableStateFlow(UiState())
  val state: StateFlow<UiState> = _state.asStateFlow()

  fun refresh() { _state.update { it.copy(loading = true) } }
}

Any caller can write to it, so state changes stop being traceable to a single owner. More on architecture

66. Business rules living in the Fragment

Gotcha

// in the Fragment
val price = items.sumOf { it.price } *
  if (user.isPrime) 0.9 else 1.0
priceView.text = format(price)

Correct

// domain
class CartTotal {
  operator fun invoke(items: List<Item>, user: User) = ...
}
// fragment
priceView.text = format(state.total)

Rules in the view can only be tested with an emulator, and the next screen reimplements them slightly differently. More on architecture

67. Constructing a ViewModel by hand

Gotcha

class Frag : Fragment() {
  private val vm = ProfileViewModel()   // new one per rotation
}

Correct

class Frag : Fragment() {
  private val vm: ProfileViewModel by viewModels()
}
// scoped to the fragment; by activityViewModels() to share

A directly constructed instance is not in the store, so it is rebuilt on every rotation and loses all its state. More on architecture

68. A singleton binding that depends on Activity scope

Gotcha

@Singleton
class Tracker @Inject constructor(
  private val activity: Activity,   // ActivityComponent
)
// error: cannot be provided in SingletonComponent

Correct

@Singleton
class Tracker @Inject constructor(
  @ApplicationContext private val ctx: Context,
)
// pass per-screen data in as method arguments

A longer-lived object holding a shorter-lived one is a leak, and Hilt rejects the graph at compile time. More on architecture

69. @HiltViewModel without an @Inject constructor

Gotcha

@HiltViewModel
class FeedViewModel(
  private val repo: FeedRepository,
) : ViewModel()

Correct

@HiltViewModel
class FeedViewModel @Inject constructor(
  private val repo: FeedRepository,
) : ViewModel()

Hilt has no way to build it, so the annotation does nothing and by viewModels() falls back to the no-arg factory. More on architecture

70. Injecting Context without a qualifier

Gotcha

class Prefs @Inject constructor(
  private val ctx: Context,   // which one?
)

Correct

class Prefs @Inject constructor(
  @ApplicationContext private val ctx: Context,
)
// @ActivityContext for anything that touches UI

Hilt binds two Contexts, so an unqualified request is ambiguous and fails the build. More on architecture

71. UI state split across several flows

Gotcha

val loading = MutableStateFlow(false)
val data = MutableStateFlow<List<Row>>(emptyList())
val error = MutableStateFlow<String?>(null)
// can be loading AND error at once

Correct

sealed interface UiState {
  data object Loading : UiState
  data class Data(val rows: List<Row>) : UiState
  data class Error(val message: String) : UiState
}
val state = MutableStateFlow<UiState>(UiState.Loading)

Independent flows emit at different times, so the UI renders combinations that are not valid states. More on architecture

72. The network DTO used as the UI model

Gotcha

@Serializable
data class UserDto(val first_name: String?, val avatar_url: String?)

Text(user.first_name ?: "")   // in the UI

Correct

data class User(val name: String, val avatar: String)

fun UserDto.toDomain() = User(
  name = first_name.orEmpty(),
  avatar = avatar_url ?: DEFAULT_AVATAR,
)

Every backend rename becomes a UI change, and nullable wire fields leak null handling into every composable. More on architecture

73. A repository that returns Retrofit Response

Gotcha

interface Repo {
  suspend fun user(): Response<UserDto>
}
// ViewModel: if (r.isSuccessful) r.body()!! else ...

Correct

interface Repo {
  suspend fun user(): Result<User>
}
// ViewModel: result.fold(::show, ::showError)

HTTP details leak all the way to the UI, so every caller repeats the same isSuccessful/body plumbing. More on architecture

74. A blocking Room query on the main thread

Gotcha

@Dao interface UserDao {
  @Query("SELECT * FROM user") fun all(): List<User>
}
val users = dao.all()   // IllegalStateException on main

Correct

@Dao interface UserDao {
  @Query("SELECT * FROM user") suspend fun all(): List<User>
  @Query("SELECT * FROM user") fun observe(): Flow<List<User>>
}

Room throws on the main thread by default, and the escape hatch just turns the crash into jank. More on data

75. fallbackToDestructiveMigration in a shipped app

Gotcha

Room.databaseBuilder(ctx, Db::class.java, "app.db")
  .fallbackToDestructiveMigration()
  .build()

Correct

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    db.execSQL("ALTER TABLE user ADD COLUMN nickname TEXT")
  }
}
Room.databaseBuilder(...).addMigrations(MIGRATION_1_2).build()

A schema bump silently deletes every row the user has, and there is no way to get it back. More on data

76. Call.execute() to get a result now

Gotcha

interface Api { @GET("user") fun user(): Call<UserDto> }

val user = api.user().execute().body()   // blocks

Correct

interface Api { @GET("user") suspend fun user(): UserDto }

val user = withContext(Dispatchers.IO) { api.user() }

execute() blocks the calling thread, so on the main thread it is a guaranteed NetworkOnMainThreadException. More on data

77. A new OkHttpClient for every request

Gotcha

fun fetch(url: String) =
  OkHttpClient().newCall(Request.Builder().url(url).build())

Correct

@Singleton
class Http @Inject constructor() {
  val client = OkHttpClient.Builder().build()
}
// one client per app; .newBuilder() to vary timeouts

Each client gets its own connection pool and thread pool, so you throw away keep-alive and leak threads. More on data

78. A ResponseBody nobody closes

Gotcha

val res = client.newCall(req).execute()
if (res.isSuccessful) return res.body!!.string()
return null   // body never closed on the failure path

Correct

client.newCall(req).execute().use { res ->
  if (res.isSuccessful) res.body?.string() else null
}

The connection stays checked out of the pool, so after a few hundred calls the client stalls waiting for one. More on data

79. A parser that rejects unknown keys

Gotcha

val json = Json   // ignoreUnknownKeys = false
json.decodeFromString<UserDto>(body)
// SerializationException on a new server field

Correct

val json = Json {
  ignoreUnknownKeys = true
  coerceInputValues = true
  explicitNulls = false
}

The backend adding one field breaks every old client in the field, which is the worst possible time to find out. More on data

80. Creating DataStore more than once

Gotcha

class Prefs(ctx: Context) {
  private val store = PreferenceDataStoreFactory.create { ctx.prefsFile() }
}
// two Prefs -> IllegalStateException

Correct

// top level, once per process
val Context.dataStore by preferencesDataStore(name = "settings")

class Prefs @Inject constructor(
  @ApplicationContext private val ctx: Context,
) { private val store = ctx.dataStore }

DataStore enforces one active instance per file, and a second one throws when it touches the same file. More on data

81. commit() on SharedPreferences

Gotcha

prefs.edit().putBoolean("dark", true).commit()

Correct

prefs.edit { putBoolean("dark", true) }   // apply(), async

// better: DataStore, which is suspend by construction
store.edit { it[DARK] = true }

commit() writes to disk synchronously on the calling thread, which is a frame drop every time you toggle a setting. More on data

82. Multi-step writes with no transaction

Gotcha

@Dao interface FeedDao {
  suspend fun clear()
  suspend fun insert(rows: List<Row>)
}
dao.clear(); dao.insert(rows)   // empty list in between

Correct

@Dao interface FeedDao {
  @Transaction
  suspend fun replace(rows: List<Row>) {
    clear()
    insert(rows)
  }
}

A failure between the two calls leaves the database half-updated, and observers see the torn state. More on data

83. A base URL without a trailing slash

Gotcha

.baseUrl("https://api.x.dev/v2")
@GET("users") // -> https://api.x.dev/users  (v2 dropped)

Correct

.baseUrl("https://api.x.dev/v2/")
@GET("users") // -> https://api.x.dev/v2/users

// a leading slash on the path also discards the base path

Retrofit resolves relative paths against the last slash, so the final path segment is silently replaced. More on data

84. runBlocking for a test with delays

Gotcha

@Test fun retries() = runBlocking {
  repo.syncWithBackoff()   // actually sleeps 30s
  assertEquals(3, api.calls)
}

Correct

@Test fun retries() = runTest {
  repo.syncWithBackoff()   // virtual time, instant
  assertEquals(3, api.calls)
}

runBlocking waits in real time, so a retry with backoff turns a unit test into a thirty second one. More on testing

85. Dispatchers.IO hardcoded inside the class

Gotcha

class Repo {
  suspend fun load() = withContext(Dispatchers.IO) { ... }
}

Correct

class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
  suspend fun load() = withContext(io) { ... }
}
// test: Repo(StandardTestDispatcher(testScheduler))

The test cannot control the scheduler, so assertions race the background work and the test flakes. More on testing

86. Testing a ViewModel without swapping Main

Gotcha

@Test fun loads() = runTest {
  val vm = FeedViewModel(repo)   // IllegalStateException
}

Correct

@Before fun setUp() = Dispatchers.setMain(StandardTestDispatcher())
@After  fun tearDown() = Dispatchers.resetMain()

// or a @get:Rule MainDispatcherRule

Dispatchers.Main needs a Looper, so viewModelScope.launch throws before your assertion ever runs. More on testing

87. Reading .value of a WhileSubscribed StateFlow

Gotcha

@Test fun state() = runTest {
  vm.load()
  assertEquals(Data(rows), vm.state.value)   // still Loading
}

Correct

@Test fun state() = runTest {
  vm.state.test {           // Turbine subscribes
    assertEquals(Loading, awaitItem())
    vm.load()
    assertEquals(Data(rows), awaitItem())
  }
}

With no subscriber the upstream never starts, so .value stays on the initial value forever. More on testing

88. Asserting with first() when order matters

Gotcha

assertEquals(Data(rows), vm.state.first())
// Loading -> Error -> Data would also pass

Correct

vm.state.test {
  assertEquals(Loading, awaitItem())
  assertEquals(Data(rows), awaitItem())
  cancelAndConsumeRemainingEvents()
}

first() cancels after one value, so a wrong or missing intermediate emission passes the test. More on testing

89. Thread.sleep to wait for the UI

Gotcha

onView(withId(R.id.load)).perform(click())
Thread.sleep(2_000)
onView(withText("Ann")).check(matches(isDisplayed()))

Correct

composeRule.onNodeWithTag("load").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
  composeRule.onAllNodesWithText("Ann")
    .fetchSemanticsNodes().isNotEmpty()
}

The sleep is either too short on a slow CI machine or wasted time on a fast one, so the suite is flaky and slow. More on testing

90. api used where implementation would do

Gotcha

dependencies {
  api(libs.retrofit)      // leaks Retrofit to every consumer
  api(libs.okhttp)
}

Correct

dependencies {
  implementation(libs.retrofit)
  // api only for types that appear in this module's public signatures
}

api puts the dependency on every consumer compile classpath, so one version bump recompiles the whole project. More on build

91. Reflection-based models with no R8 keep rule

Gotcha

# proguard-rules.pro
# (empty)

# debug parses fine, release returns a DTO with every field null,
# because R8 renamed the fields Gson looks up by name

Correct

# proguard-rules.pro
-keepclassmembers,allowobfuscation class * {
  @com.google.gson.annotations.SerializedName <fields>;
}

# or move to kotlinx.serialization, which needs no reflection

R8 renames the fields it cannot see being used, so parsing silently returns nulls in release only. More on build

92. minifyEnabled false on the release build

Gotcha

release {
  isMinifyEnabled = false
  isShrinkResources = false
}

Correct

release {
  isMinifyEnabled = true
  isShrinkResources = true
  proguardFiles(
    getDefaultProguardFile("proguard-android-optimize.txt"),
    "proguard-rules.pro",
  )
}

You ship every unused class and readable names, so the APK is larger and trivially decompiled. More on build

93. Decoding a full-resolution bitmap

Gotcha

val bmp = BitmapFactory.decodeFile(path)
imageView.setImageBitmap(bmp)   // OutOfMemoryError

Correct

imageView.load(File(path)) {
  size(imageView.width, imageView.height)
}
// by hand: BitmapFactory.Options(inSampleSize = n)

A 12MP photo is roughly 48MB in ARGB_8888, so a couple of them blow the heap on a mid-range device. More on performance

94. notifyDataSetChanged for every update

Gotcha

fun submit(rows: List<Row>) {
  this.rows = rows
  notifyDataSetChanged()
}

Correct

class RowAdapter : ListAdapter<Row, VH>(DIFF) {
  companion object {
    val DIFF = object : DiffUtil.ItemCallback<Row>() {
      override fun areItemsTheSame(a: Row, b: Row) = a.id == b.id
      override fun areContentsTheSame(a: Row, b: Row) = a == b
    }
  }
}

It rebinds every visible row and kills item animations, so the list flickers and loses scroll position. More on performance

95. Every SDK initialised in Application.onCreate

Gotcha

class App : Application() {
  override fun onCreate() {
    Analytics.init(this)
    Crash.init(this)
    Ads.init(this)      // 400ms before any UI
  }
}

Correct

class App : Application() {
  override fun onCreate() {
    Crash.init(this)    // must be first, keep it
  }
}
// the rest: androidx.startup Initializer, or lazy on first use

It all runs before the first frame, so cold start grows by the sum of every SDK you will ever add. More on performance

96. Real work inside onBindViewHolder

Gotcha

override fun onBindViewHolder(h: VH, i: Int) {
  val user = db.userDao().byId(rows[i].userId)   // disk read
  h.name.text = formatDate(rows[i].at)           // allocates
}

Correct

// map to a ready-to-render model off the main thread
data class RowUi(val name: String, val date: String)

override fun onBindViewHolder(h: VH, i: Int) {
  h.name.text = getItem(i).name
  h.date.text = getItem(i).date
}

Bind runs on the main thread during scroll, so anything beyond assignment shows up as dropped frames. More on performance

97. An auth token in plain SharedPreferences

Gotcha

prefs.edit { putString("access_token", token) }

Correct

val keyAlias = MasterKey.Builder(ctx)
  .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()

EncryptedSharedPreferences.create(
  ctx, "secure", keyAlias, AES256_SIV, AES256_GCM,
).edit { putString("access_token", token) }

On a rooted or backed-up device the file is readable, and the token is a full account takeover. More on security

98. A TrustManager that accepts everything

Gotcha

val tm = object : X509TrustManager {
  override fun checkServerTrusted(c: Array<X509Certificate>, a: String) {}
  override fun checkClientTrusted(c: Array<X509Certificate>, a: String) {}
  override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}

Correct

// res/xml/network_security_config.xml for debug-only trust,
// and pin the production host:
val pinner = CertificatePinner.Builder()
  .add("api.x.dev", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
  .build()
OkHttpClient.Builder().certificatePinner(pinner).build()

It disables the entire TLS chain check, so any proxy on the network can read and rewrite every request. More on security

99. An exported component with no permission

Gotcha

<receiver
  android:name=".SyncReceiver"
  android:exported="true" />

Correct

<receiver
  android:name=".SyncReceiver"
  android:exported="false" />

<!-- if it genuinely must be public, gate it: -->
<!-- android:permission="com.x.permission.SYNC" -->

Any app on the device can send it an Intent, so an internal action becomes a public API by accident. More on security

100. Logging the request or the token

Gotcha

Log.d("Api", "auth=" + token)
HttpLoggingInterceptor().setLevel(Level.BODY)   // in release

Correct

HttpLoggingInterceptor()
  .apply { level = if (BuildConfig.DEBUG) Level.BASIC else Level.NONE }
  .apply { redactHeader("Authorization") }

Logcat is readable by adb and captured by crash reporters, so the secret leaves the device with the bug report. More on security