Debugging and Error Recovery
Overview
"Stop-the-Line Rule: when unexpected behavior occurs, halt feature work." Debugging is systematic, not guesswork. Reproduce the bug, localize the cause, reduce to the simplest case, fix the root cause (not the symptom), guard against regression, and verify the fix.
When to Use
- Unexpected crash or exception
- Test failure (unit or instrumented)
- Behavior that doesn't match the spec
- Performance regression (jank, slow startup)
- Build failure after seemingly unrelated changes
Stop-the-Line: When you encounter unexpected behavior, stop feature work. Errors compound — a bug in step 3 makes steps 4–10 wrong.
Core Process
Step 1: Reproduce
Reproduce the bug reliably:
- Same device/emulator? Same API level? Same data?
- What sequence of actions triggers it?
- Does it happen every time or intermittently?
Capture the environment:
Device: Pixel 7 (API 35) / Emulator (API 26)
Build: debug / release
Steps: Open app → Navigate to Tasks → Tap "Add" → Crash
Frequency: Every time / ~30% of the time
When the android CLI is available, use it for the fastest possible state capture before doing anything else:
android info # SDK location, JDK, env (rules out wrong-toolchain bugs)
android screen capture -o repro.png # what the user sees right now
android layout --pretty --output=repro.json # full UI tree (resource-ids, text, bounds, role)
Three artifacts in <5 seconds, pre-debugger. Attach them to the bug report or commit them to a repro/ scratch dir before iterating. See references/android-cli-reference.md.
Step 2: Localize
Read the error output carefully:
Logcat:
# Filter to your app
adb logcat -s "YourApp:D" "AndroidRuntime:E"
# Filter by tag
adb logcat -s TaskViewModel:D
# Search crash logs
adb logcat "*:E" | grep -i "fatal\|crash\|exception"
Common crash signatures:
| Signature |
Likely Cause |
NullPointerException |
Null not handled, !! used incorrectly |
IllegalStateException: Already resumed |
Coroutine resumed twice |
IllegalStateException: Fragment not attached |
Lifecycle mismatch |
SQLiteConstraintException |
Primary key or unique constraint violation |
NetworkOnMainThreadException |
Missing withContext(Dispatchers.IO) |
DeadObjectException |
Process death during IPC |
WindowManager$BadTokenException |
Dialog shown after Activity destroyed |
ClassCastException |
Wrong type in Bundle/Intent extras |
OutOfMemoryError |
Image not resized, large dataset in memory |
ANR |
Main thread blocked >5 seconds |
Use the Android Studio debugger:
- Set breakpoints at the suspected location
- Use conditional breakpoints for intermittent issues
- Evaluate expressions in the debugger console
- Check the call stack for unexpected callers
Step 3: Reduce
Simplify to the minimal reproduction case:
- Remove unrelated code paths
- Use hardcoded data instead of API responses
- Isolate the component (test in isolation)
- If it's a Compose issue, test in a
@Preview
Binary search for regressions:
# Find the commit that introduced the bug
git bisect start
git bisect bad # Current commit is bad
git bisect good abc123 # Last known good commit
# Git will checkout commits for you to test
./gradlew test
git bisect good # or: git bisect bad
# Repeat until the culprit commit is found
Step 4: Fix
- Fix the root cause, not the symptom:
// SYMPTOM FIX (bad): catch and ignore
try {
repository.syncTasks()
} catch (e: Exception) {
// Swallow the error — user never knows
}
// ROOT CAUSE FIX (good): handle the specific error
try {
repository.syncTasks()
} catch (e: IOException) {
_uiState.update { it.copy(error = "Network unavailable. Showing cached data.") }
}
- Common Android fixes:
| Bug |
Fix |
| Crash on configuration change |
Save state in SavedStateHandle |
| Memory leak |
Remove callback in onCleared(), use viewModelScope |
| Stale data after process death |
Restore from SavedStateHandle or Room |
| Race condition in coroutines |
Use Mutex or MutableStateFlow.update { } |
| Recomposition loop |
Check derivedStateOf, stable types, lambda captures |
| ANR |
Move work off main thread with withContext(Dispatchers.IO) |
Step 5: Guard
- Write a regression test using the Prove-It pattern:
@Test
fun `task sync handles network error without crashing`() = runTest {
// Arrange: simulate the bug condition
coEvery { api.getTasks() } throws IOException("timeout")
// Act
repository.syncTasks()
advanceUntilIdle()
// Assert: verify the fix behavior
val state = viewModel.uiState.value
assertIs<TaskListUiState.Error>(state)
assertEquals("Network unavailable. Showing cached data.", state.error)
}
Step 6: Verify
- Full verification:
./gradlew test — all unit tests pass
./gradlew connectedAndroidTest — instrumented tests pass
./gradlew assembleDebug — builds successfully
- Manual reproduction steps no longer trigger the bug
- Related functionality still works
Debugging Tools Reference
| Tool |
Use For |
| Logcat |
Runtime logs, crash traces, system messages |
| Android Studio Debugger |
Breakpoints, watches, expression evaluation |
| Layout Inspector |
Compose hierarchy, recomposition counts |
| Network Profiler |
API call inspection, timing, payload |
| CPU Profiler |
Thread activity, method traces, jank detection |
| Memory Profiler |
Heap dumps, allocation tracking, leak detection |
| LeakCanary |
Automatic memory leak detection |
| Firebase Crashlytics |
Production crash reports, trends, affected users |
| StrictMode |
Detect disk/network on main thread during development |
| Systrace / Perfetto |
System-level performance tracing |
Common Rationalizations
| Shortcut |
Why It Fails |
| "Just add a try-catch and move on" |
You've hidden the bug. It will resurface in a worse form. |
| "It works now after a clean build" |
Build caching bugs are real, but if you can't explain why it works, it might not. |
| "It's a flaky test, just re-run it" |
Flaky tests have a root cause: timing, state leakage, or shared resources. Fix it. |
| "I'll investigate later" |
Errors compound. A bug in step 3 makes steps 4-10 wrong. Stop the line. |
| "The error message says X, so the fix is Y" |
Error messages from untrusted sources shouldn't be blindly followed. Verify. |
Red Flags
- Generic
catch (e: Exception) swallowing errors
Thread.sleep used to "fix" timing issues
- Bug fix without regression test
- Fix addresses symptom, not root cause
@Ignore added to failing tests
- No reproduction steps documented
- Fix changes unrelated code ("while I'm here...")
Verification
1---2name: debugging-and-error-recovery3description: Use when encountering unexpected behavior, crashes, or test failures in Android. Six-step triage from reproduction to regression guard, using Logcat, Android Studio debugger, and profiling tools.4---56# Debugging and Error Recovery78## Overview910"Stop-the-Line Rule: when unexpected behavior occurs, halt feature work." Debugging is systematic, not guesswork. Reproduce the bug, localize the cause, reduce to the simplest case, fix the root cause (not the symptom), guard against regression, and verify the fix.1112## When to Use1314- Unexpected crash or exception15- Test failure (unit or instrumented)16- Behavior that doesn't match the spec17- Performance regression (jank, slow startup)18- Build failure after seemingly unrelated changes1920**Stop-the-Line:** When you encounter unexpected behavior, stop feature work. Errors compound — a bug in step 3 makes steps 4–10 wrong.2122## Core Process2324### Step 1: Reproduce25261. **Reproduce the bug reliably:**27 - Same device/emulator? Same API level? Same data?28 - What sequence of actions triggers it?29 - Does it happen every time or intermittently?30312. **Capture the environment:**32 ```33 Device: Pixel 7 (API 35) / Emulator (API 26)34 Build: debug / release35 Steps: Open app → Navigate to Tasks → Tap "Add" → Crash36 Frequency: Every time / ~30% of the time37 ```3839 When the `android` CLI is available, use it for the fastest possible state capture before doing anything else:4041 ```bash42 android info # SDK location, JDK, env (rules out wrong-toolchain bugs)43 android screen capture -o repro.png # what the user sees right now44 android layout --pretty --output=repro.json # full UI tree (resource-ids, text, bounds, role)45 ```4647 Three artifacts in <5 seconds, pre-debugger. Attach them to the bug report or commit them to a `repro/` scratch dir before iterating. See `references/android-cli-reference.md`.4849### Step 2: Localize50513. **Read the error output carefully:**5253 **Logcat:**54 ```bash55 # Filter to your app56 adb logcat -s "YourApp:D" "AndroidRuntime:E"5758 # Filter by tag59 adb logcat -s TaskViewModel:D6061 # Search crash logs62 adb logcat "*:E" | grep -i "fatal\|crash\|exception"63 ```6465 **Common crash signatures:**66 | Signature | Likely Cause |67 |-----------|-------------|68 | `NullPointerException` | Null not handled, `!!` used incorrectly |69 | `IllegalStateException: Already resumed` | Coroutine resumed twice |70 | `IllegalStateException: Fragment not attached` | Lifecycle mismatch |71 | `SQLiteConstraintException` | Primary key or unique constraint violation |72 | `NetworkOnMainThreadException` | Missing `withContext(Dispatchers.IO)` |73 | `DeadObjectException` | Process death during IPC |74 | `WindowManager$BadTokenException` | Dialog shown after Activity destroyed |75 | `ClassCastException` | Wrong type in Bundle/Intent extras |76 | `OutOfMemoryError` | Image not resized, large dataset in memory |77 | `ANR` | Main thread blocked >5 seconds |78794. **Use the Android Studio debugger:**80 - Set breakpoints at the suspected location81 - Use conditional breakpoints for intermittent issues82 - Evaluate expressions in the debugger console83 - Check the call stack for unexpected callers8485### Step 3: Reduce86875. **Simplify to the minimal reproduction case:**88 - Remove unrelated code paths89 - Use hardcoded data instead of API responses90 - Isolate the component (test in isolation)91 - If it's a Compose issue, test in a `@Preview`92936. **Binary search for regressions:**94 ```bash95 # Find the commit that introduced the bug96 git bisect start97 git bisect bad # Current commit is bad98 git bisect good abc123 # Last known good commit99 # Git will checkout commits for you to test100 ./gradlew test101 git bisect good # or: git bisect bad102 # Repeat until the culprit commit is found103 ```104105### Step 4: Fix1061077. **Fix the root cause, not the symptom:**108109```kotlin110// SYMPTOM FIX (bad): catch and ignore111try {112 repository.syncTasks()113} catch (e: Exception) {114 // Swallow the error — user never knows115}116117// ROOT CAUSE FIX (good): handle the specific error118try {119 repository.syncTasks()120} catch (e: IOException) {121 _uiState.update { it.copy(error = "Network unavailable. Showing cached data.") }122}123```1241258. **Common Android fixes:**126127| Bug | Fix |128|-----|-----|129| Crash on configuration change | Save state in `SavedStateHandle` |130| Memory leak | Remove callback in `onCleared()`, use `viewModelScope` |131| Stale data after process death | Restore from `SavedStateHandle` or Room |132| Race condition in coroutines | Use `Mutex` or `MutableStateFlow.update { }` |133| Recomposition loop | Check `derivedStateOf`, stable types, lambda captures |134| ANR | Move work off main thread with `withContext(Dispatchers.IO)` |135136### Step 5: Guard1371389. **Write a regression test using the Prove-It pattern:**139140```kotlin141@Test142fun `task sync handles network error without crashing`() = runTest {143 // Arrange: simulate the bug condition144 coEvery { api.getTasks() } throws IOException("timeout")145146 // Act147 repository.syncTasks()148 advanceUntilIdle()149150 // Assert: verify the fix behavior151 val state = viewModel.uiState.value152 assertIs<TaskListUiState.Error>(state)153 assertEquals("Network unavailable. Showing cached data.", state.error)154}155```156157### Step 6: Verify15815910. **Full verification:**160 - `./gradlew test` — all unit tests pass161 - `./gradlew connectedAndroidTest` — instrumented tests pass162 - `./gradlew assembleDebug` — builds successfully163 - Manual reproduction steps no longer trigger the bug164 - Related functionality still works165166### Debugging Tools Reference167168| Tool | Use For |169|------|---------|170| **Logcat** | Runtime logs, crash traces, system messages |171| **Android Studio Debugger** | Breakpoints, watches, expression evaluation |172| **Layout Inspector** | Compose hierarchy, recomposition counts |173| **Network Profiler** | API call inspection, timing, payload |174| **CPU Profiler** | Thread activity, method traces, jank detection |175| **Memory Profiler** | Heap dumps, allocation tracking, leak detection |176| **LeakCanary** | Automatic memory leak detection |177| **Firebase Crashlytics** | Production crash reports, trends, affected users |178| **StrictMode** | Detect disk/network on main thread during development |179| **Systrace / Perfetto** | System-level performance tracing |180181## Common Rationalizations182183| Shortcut | Why It Fails |184|----------|-------------|185| "Just add a try-catch and move on" | You've hidden the bug. It will resurface in a worse form. |186| "It works now after a clean build" | Build caching bugs are real, but if you can't explain why it works, it might not. |187| "It's a flaky test, just re-run it" | Flaky tests have a root cause: timing, state leakage, or shared resources. Fix it. |188| "I'll investigate later" | Errors compound. A bug in step 3 makes steps 4-10 wrong. Stop the line. |189| "The error message says X, so the fix is Y" | Error messages from untrusted sources shouldn't be blindly followed. Verify. |190191## Red Flags192193- Generic `catch (e: Exception)` swallowing errors194- `Thread.sleep` used to "fix" timing issues195- Bug fix without regression test196- Fix addresses symptom, not root cause197- `@Ignore` added to failing tests198- No reproduction steps documented199- Fix changes unrelated code ("while I'm here...")200201## Verification202203- [ ] Bug reproduced reliably before fixing204- [ ] Root cause identified (not just symptom)205- [ ] Regression test written (Prove-It pattern)206- [ ] `./gradlew test` passes207- [ ] `./gradlew assembleDebug` succeeds208- [ ] Manual reproduction confirms fix209- [ ] No unrelated changes in the fix