# Onboarding Crash & Debrid Auth Debugging (2026-05-20)

Use this when diagnosing startup crashes, onboarding button failures, or stuck debrid auth screens in NexStream.

## CrashLogger.kt — Temporary On-Device Rolling Log

A lightweight rolling text logger for Android TV debug builds. Captures uncaught exceptions and explicit errors to the app's private files directory.

### Implementation

`app/src/main/java/com/nexstream/app/util/CrashLogger.kt`

```kotlin
object CrashLogger {
    private const val MAX_LOG_SIZE_BYTES = 256 * 1024L
    private const val MAX_HISTORY = 3

    fun init(context: Context) {
        val logDir = File(context.filesDir, "crash_logs").apply { mkdirs() }
        val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
            writeEntry("FATAL", "Uncaught exception on thread ${thread.name}", throwable)
            defaultHandler?.uncaughtException(thread, throwable)
        }
    }

    fun logError(tag: String, message: String, throwable: Throwable? = null)
    fun logWarning(tag: String, message: String, throwable: Throwable? = null)
    fun readLog(): String
    fun clearLogs()
}
```

Key design choices:
- Writes asynchronously via `GlobalScope.launch(Dispatchers.IO)` so the crashing thread never blocks.
- 256 KB cap with automatic rotation (keeps `.1`, `.2`, `.3` history files).
- Logs are in the app's private storage — accessible via `adb shell run-as` on debug builds.
- **NOT a replacement for Crashlytics** — remove or replace before any public release.

### Wiring into Application

```kotlin
// NexStreamApplication.kt — init after Hilt setup
override fun onCreate() {
    super.onCreate()
    SingletonImageLoader.setSafe { imageLoader }
    CrashLogger.init(this)
}
```

### Pulling logs

```bash
# Debug builds only (debuggable=true)
adb shell run-as com.nexstream.app cat files/crash_logs/nexstream_errors.log

# Or copy the whole log dir
adb shell run-as com.nexstream.app tar -c -C files/crash_logs . | tar -x -C /tmp/nexstream_logs
```

If the log file is **empty** but the app still crashes immediately, the crash happens **before** `Application.onCreate` finishes — usually a native library failure or Room migration throwing during `provideDatabase()` before Hilt can inject anything. In that case use `adb logcat`.

## Onboarding Crash Diagnosis Flow

### Buttons that crash

| Button | Code path | Danger zone |
|--------|-----------|-------------|
| **Skip to Watch** (Step 1) | `OnboardingViewModel.skipAll()` | `ensureActiveProfile()` → Room insert/query |
| **Already have an account?** (Step 0) | `OnboardingViewModel.complete()` | `ensureActiveProfile()` → Room insert/query → `setFirstLaunch(false)` (DataStore) |

Both paths hit the same two operations that can throw:
1. **Room** — `profileDao.insert()` / `profileDao.getAll()` (creates/reads "Default" profile)
2. **DataStore** — `preferencesManager.setFirstLaunch(false)` (writes `is_first_launch` boolean)

### Hardening fix (already applied)

Wrap both paths in `try/catch`, log to `CrashLogger`, and surface the error in UI state instead of crashing:

```kotlin
fun skipAll() {
    viewModelScope.launch {
        try {
            ensureActiveProfile()
            preferencesManager.setFirstLaunch(false)
            _uiState.update { it.copy(isComplete = true) }
        } catch (e: Exception) {
            CrashLogger.logError("OnboardingViewModel", "skipAll failed", e)
            _uiState.update { it.copy(debridStatus = "failed", debridError = "Setup error: ${e.message}") }
        }
    }
}
```

### Root cause checklist

1. **Room database corruption**  
   Signature: `SQLiteDatabaseCorruptException`, `IllegalStateException: Room … migration …`  
   Fix: `adb shell pm clear com.nexstream.app` (wipes ALL data, debug only)

2. **Migration mismatch**  
   `NexStreamDatabase` is version `3`. `MIGRATION_1_2` and `MIGRATION_2_3` are registered in `LocalDataModule`. If entity schemas drift from migration SQL, Room throws on first access.  
   Fix: Verify migration SQL matches `@Entity` definitions exactly. Temporarily add `.fallbackToDestructiveMigration()` (debug only) to confirm.

3. **Profile insertion race**  
   `LocalDataModule.provideDatabase()` seeds a default profile via `onCreate()` callback. `EnsureActiveProfileUseCase` also creates one if none exist. Both can race depending on DAO implementation.  
   Fix: Ensure DAO insert handles conflict or duplicate-key gracefully.

### Re-open crash (app dies immediately every time)

`LaunchViewModel` runs on every cold start:

```kotlin
combine(
    preferencesManager.isFirstLaunch,
    preferencesManager.activeProfileId,
    profileDao.observeAll(),   // ← crashes here if Room is corrupt
) { ... }
```

If the first crash corrupted SQLite, or migration throws during `observeAll()`, the app dies before any UI draws.  
Fix: Same as above — `pm clear` to wipe corrupted DB.

## Debrid Auth Polling — "Waiting for you to authorize…" Stuck

### How the flow works

1. `startDebridFlow(REAL_DEBRID)` → fetches device code from `api.real-debrid.com/oauth/v2/device/code`
2. `pollDebridAuth()` → POSTs to `/oauth/v2/token` every `interval` seconds for up to 5 minutes
3. When user authorizes on the website, the next POST returns `access_token`
4. Token is stored and status becomes `"connected"`

### Why it got stuck (old code)

Old `pollRealDebrid()` used a catch-all:

```kotlin
catch (e: Exception) {
    Log.d(TAG, "Polling Real-Debrid: not yet authorized")
}
```

This treated **network down, HTTP 403 access_denied, deserialization errors, and rate limits** identically to "user hasn't approved yet." The UI just kept spinning for the full 5-minute timeout.

### Fix (error classification)

Split into specific exception types:

| HTTP / Exception | Classification | Behaviour |
|-----------------|----------------|-----------|
| `400` | `authorization_pending` | Normal — keep polling silently |
| `403` | `access_denied` | User denied — log warning, surface failure |
| `429` | `rate_limited` | Log warning, keep polling |
| `IOException` | `network_error` | Log to CrashLogger, keep polling |
| Other exception | `unexpected` | Log to CrashLogger as error |

```kotlin
// DebridAuthManager.kt — pollRealDebrid()
try {
    val response = realDebridApi.get().exchangeDeviceCode(...)
    if (response.accessToken.isNotBlank()) return response.accessToken
} catch (e: HttpException) {
    val code = e.code()
    lastError = when (code) {
        400 -> "authorization_pending"
        403 -> "access_denied"
        429 -> "rate_limited"
        else -> "http_$code"
    }
    if (code != 400) CrashLogger.logWarning("DebridAuthManager", "RD poll HTTP $code", e)
} catch (e: IOException) {
    lastError = "network_error"
    CrashLogger.logWarning("DebridAuthManager", "RD poll network error", e)
} catch (e: Exception) {
    lastError = "unexpected: ${e.javaClass.simpleName}"
    CrashLogger.logError("DebridAuthManager", "RD poll unexpected error", e)
}
```

### Verification commands

```bash
# Filter logcat for auth manager
adb logcat -s DebridAuthManager:D | grep -E "token obtained|timeout|HTTP|network|unexpected"
```

**Expected after approval:** `Real-Debrid OAuth token obtained` within ~10 seconds  
**Repeated HTTP 400:** User hasn't approved yet — check code/URL  
**HTTP 403:** User tapped "Deny" — UI should now surface failure state  
**`unexpected:`:** Deserialization or API contract problem — pull CrashLogger log

## Diagnostic Command Reference

```bash
# Full crash log dump
adb logcat -d | grep -E "AndroidRuntime|FATAL|Room|SQLite|IllegalState|CrashLogger" | tail -n 50

# Inspect Room DB directly
adb shell run-as com.nexstream.app cp databases/nexstream_db /sdcard/nexstream_db_pull
adb pull /sdcard/nexstream_db_pull ./nexstream_db_debug.sqlite
sqlite3 nexstream_db_debug.sqlite ".tables"

# List DataStore files
adb shell run-as com.nexstream.app ls files/datastore/

# Force first-launch state (simulate fresh install)
adb shell pm clear com.nexstream.app
# Or selectively:
adb shell run-as com.nexstream.app rm files/datastore/nexstream_preferences.preferences_pb
```

## ProfileSelectScreen FocusRequester Crash — "FocusRequester is not initialized"

### Symptom
App completes onboarding (or skip), navigates to `ProfileSelectScreen`, then immediately crashes:

```
java.lang.IllegalStateException: FocusRequester is not initialized
    at ProfileSelectScreen.kt:126
```

### Root cause
A `FocusRequester` was created inside the screen but **never attached** to any composable via `Modifier.focusRequester()`. `LaunchedEffect(Unit)` immediately called `requestFocus()` before the lazy grid had mounted any items.

### Fix pattern

```kotlin
val allItems = listState.profiles + null // null = "Add Profile"
val focusRequester = remember { FocusRequester() }

// 1. Use itemsIndexed to know which item is first
// 2. Attach FocusRequester ONLY to the first item
// 3. Add a small delay so LazyVerticalGrid has time to mount
// 4. Wrap requestFocus() in try/catch as a safety net

LaunchedEffect(allItems) {
    if (allItems.isNotEmpty()) {
        kotlinx.coroutines.delay(150)
        try {
            focusRequester.requestFocus()
        } catch (_: IllegalStateException) {
            // Safe to ignore — D-pad will naturally land on first item
        }
    }
}

LazyVerticalGrid(columns = GridCells.Fixed(4)) {
    itemsIndexed(allItems, key = { _, item -> item?.id ?: -1 }) { index, profile ->
        val itemModifier = if (index == 0) {
            Modifier.focusRequester(focusRequester)
        } else Modifier

        if (profile != null) {
            ProfileCard(modifier = itemModifier, ...)
        } else {
            AddProfileCard(modifier = itemModifier, ...)
        }
    }
}
```

Key rules:
- `Modifier.focusRequester()` must be attached to the composable that receives focus.
- In lazy containers, the item may not exist at composition start — delay + try/catch handles the race.
- If the first item is an "Add Profile" card (null in the list), the focus requester attaches there instead.

## NavRail LazyColumn Duplicate Key Crash

### Symptom
App opens, shows profile grid, user selects a profile → immediate crash on HomeScreen load:

```
java.lang.IllegalArgumentException: Key "details/{type}/{id}" was already used.
    If you are using LazyColumn/Row please make sure you provide a unique key for each item.
```

### Root cause
`NavRail.kt` used `item.route` as the `LazyColumn` key. But Movies, Shows, and Anime are all `NavRailItem(Screen.Details, ...)` — they share the same route string `"details/{type}/{id}"`. Three identical keys in one LazyColumn = Compose throws.

### Fix
Use a **guaranteed-unique field** as the key. Labels are unique per nav item, even when the route is not:

```kotlin
// NavRail.kt — LazyColumn itemsIndexed
itemsIndexed(
    items = navItems,
    key = { _, item -> item.label }, // labels are unique
) { _, navItem ->
    // ...
}
```

General rule: When multiple UI items navigate to the same destination (e.g. category tabs, filter chips, nav rail shortcuts), never use `route` or `destination` as the lazy key. Use `label`, `id`, or a synthetic composite key (`"${label}_${index}"`).

## Files changed in this session

| File | Change |
|------|--------|
| `NexStreamApplication.kt` | `CrashLogger.init(this)` |
| `util/CrashLogger.kt` | New — rolling log file |
| `OnboardingViewModel.kt` | `try/catch` around `skipAll()` and `complete()` |
| `DebridAuthManager.kt` | Granular exception classification in `pollRealDebrid()` |
| `ui/screens/profiles/ProfileSelectScreen.kt` | FocusRequester attached to first lazy item + delay + try/catch |
| `ui/components/NavRail.kt` | LazyColumn key changed from `item.route` to `item.label` |
| `docs/TROUBLESHOOTING_ONBOARDING_CRASHES_AND_AUTH.md` | Full troubleshooting guide |
