# Cleartext HTTP Addon Crash — Full Diagnostic & Fix Pattern

**Session:** 2026-06-02  
**Crash:** `java.net.UnknownServiceException: CLEARTEXT communication to 192.168.1.50 not permitted by network security policy`

---

## Crash Chain (4-layer propagation)

```
1. AddonRepository.initialize()
  → cacheLoadScope.launch { unifiedAddonStore.refreshAllSystemManifests() }
2. UnifiedAddonStore.refreshAllSystemManifests()
  → for (entity in addonDao.getSystemAddons()) { refreshManifest(id, url) }
3. ManifestUpdateManager.fetchAndValidate(manifestUrl)
  → client.loadManifest(manifestUrl)
4. StremioAddonClientImpl.loadManifest()
  → runCatchingWithTimeout(url) { api.getManifest(manifestUrl) }
5. OkHttp/Retrofit → UnknownServiceException (cleartext blocked)
  → runCatchingWithTimeout only catches timeout, NOT this exception
  → propagates up through ALL 4 layers UNCAUGHT
  → app crash
```

**Root cause:** `runCatchingWithTimeout()` used `withTimeoutOrNull` which only handles `TimeoutCancellationException`. All other exceptions (network policy, DNS, TLS) propagated uncaught through the entire call chain.

---

## Offending Addon

| Field | Value |
|--------|-------|
| Addon ID | `nexstream_scraper` |
| Display name | NexStream Scraper |
| Source file | `SystemAddonCatalog.kt` line 132-142 |
| URL source | `build.gradle.kts` line 74-75 (`NEXSTREAM_SCRAPER_URL`) |
| Resolved URL | `http://192.168.1.50:3091/manifest.json` |
| Intent | Production default — self-hosted multi-provider scraper |

---

## Five-Layer Fix

### 1. Network Security Config — `network_security_config.xml`

Android's network security config uses DNS-domain matching, NOT CIDR subnet matching.

```xml
<!-- CORRECT — matches 192.168.1.50 via "subdomain" matching -->
<domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="true">192.168</domain>
</domain-config>

<!-- WRONG — only matches the literal string "192.168.0.0" -->
<domain includeSubdomains="true">192.168.0.0</domain>
```

**How it works:** Android treats the domain as a reverse-DNS hostname. `includeSubdomains="true"` on `192.168` means remaining octets (e.g., `1.50`) are treated as "subdomains." This is the correct way to match all 192.168.x.x addresses.

### 2. Exception Catching — `StremioAddonClientImpl.runCatchingWithTimeout()`

```kotlin
private suspend fun <T> runCatchingWithTimeout(
    addonUrl: String,
    block: suspend () -> T,
): Result<T> {
    val timeoutMs = DEFAULT_TIMEOUT_MS
    return try {
        val result = withTimeoutOrNull(timeoutMs) { block() }
        if (result != null) {
            Result.success(result)
        } else {
            Result.failure(IOException("Addon timed out after ${timeoutMs}ms"))
        }
    } catch (e: UnknownServiceException) {
        Result.failure(IOException(
            "HTTP cleartext blocked by network security policy: ${addonUrl.take(60)}. Use HTTPS or enable local debug addons."
        ))
    } catch (e: IOException) {
        Result.failure(e)
    } catch (e: Exception) {
        Result.failure(IOException(
            "Unexpected error: ${e.message?.take(200) ?: e.javaClass.simpleName}"
        ))
    }
}
```

### 3. Per-Addon Isolation — `UnifiedAddonStore.refreshAllSystemManifests()`

```kotlin
suspend fun refreshAllSystemManifests(): Int {
    var updated = 0
    for (entity in addonDao.getSystemAddons()) {
        if (entity.isNative) continue
        val url = entity.manifestUrl
        if (url.isBlank()) continue
        try {
            val result = refreshManifest(entity.addonId, url)
            if (result.success) updated++
        } catch (e: Exception) {
            Log.w(TAG, "Manifest refresh CRASHED for ${entity.addonId}: ${e.message}", e)
            addonDao.updateHealthStatus(
                entity.addonId,
                UnifiedHealthStatus.BLOCKED_CLEARTEXT.name.takeIf {
                    e is UnknownServiceException || e.message?.contains("CLEARTEXT") == true
                } ?: UnifiedHealthStatus.OFFLINE.name,
                e.message?.take(200) ?: "Refresh error"
            )
        }
    }
    return updated
}
```

### 4. Health Status Enum — `UnifiedAddonCategory.kt`

```kotlin
enum class UnifiedHealthStatus(val displayName: String) {
    HEALTHY("Healthy"),
    DEGRADED("Degraded"),
    OFFLINE("Offline"),
    CONFIG_REQUIRED("Configuration required"),
    BLOCKED_CLEARTEXT("Blocked — HTTP cleartext not allowed"),  // NEW
    UNKNOWN("Unknown"),
    ;

    fun toAddonHealth(): AddonHealth = when (this) {
        HEALTHY -> AddonHealth.HEALTHY
        DEGRADED -> AddonHealth.SLOW
        OFFLINE -> AddonHealth.OFFLINE
        CONFIG_REQUIRED -> AddonHealth.UNKNOWN
        BLOCKED_CLEARTEXT -> AddonHealth.OFFLINE
        UNKNOWN -> AddonHealth.UNKNOWN
    }
}
```

### 5. DAO Health Update — `AddonDao.kt`

```kotlin
@Query("UPDATE addons SET healthStatus = :status, lastError = :error, lastCheckedAt = :checkedAt WHERE addonId = :addonId")
suspend fun updateHealthStatus(addonId: String, status: String, error: String?, checkedAt: Long = System.currentTimeMillis())
```

---

## Diagnostic Commands

```bash
# Find the offending addon
grep -rn "192\.168" app/src/main/java/com/unspooled/app/data/addon/SystemAddonCatalog.kt
grep -rn "192\.168" app/build.gradle.kts

# Check current network security config
cat app/src/main/res/xml/network_security_config.xml | grep -A5 "192.168"

# Verify BuildConfig after generation
grep "NEXSTREAM_SCRAPER_URL" app/build/generated/source/buildConfig/debug/com/unspooled/app/BuildConfig.java
```

---

## Files Changed (June 2026)

| File | Change |
|------|--------|
| `network_security_config.xml` | `192.168.0.0` → `192.168` for correct IP-range matching |
| `StremioAddonClientImpl.kt` | `runCatchingWithTimeout` catches `UnknownServiceException`, `IOException`, `Exception` |
| `UnifiedAddonStore.kt` | Per-addon try/catch in `refreshAllSystemManifests`, marks as `BLOCKED_CLEARTEXT` |
| `UnifiedAddonCategory.kt` | New `BLOCKED_CLEARTEXT` enum value |
| `AddonDao.kt` | New `updateHealthStatus()` method |
