From 277d1cbe23d4f0d31e64da318c0c408dcc2078fe Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 17 Aug 2026 10:49:25 +0530 Subject: [PATCH] refactor(core): combine and squash refactoring PRs and CodeRabbit review fixes - PR #59: harden exception safety and json parsing in scraper & repository layers - PR #61: optimize memory by extracting regexes and harden json error handling - PR #62: harden exception safety, Trakt auth checks, and watch history cancellation - PR #63: preserve coroutine cancellation across sync, home server, and catalog layers - PR #64: propagate cancellation across viewmodels and UI components - PR #65: optimize stream parsing layers and source attribution regexes - PR #66: harden exception safety and edge-case validation in watchlist & network layers --- .github/workflows/build-check.yml | 34 +-- .github/workflows/deploy-web.yml | 2 +- .github/workflows/ios-testflight.yml | 2 +- .../tv/data/repository/AuthRepository.kt | 13 +- .../tv/data/repository/CloudSyncRepository.kt | 240 +++++++++++------- .../data/repository/HomeServerRepository.kt | 143 +++++++---- .../repository/HttpLocalScraperRuntime.kt | 71 ++++-- .../repository/IptvPlaybackUrlResolver.kt | 16 +- .../tv/data/repository/MediaRepository.kt | 17 +- .../tv/data/repository/StreamRepository.kt | 27 +- .../data/repository/WatchHistoryRepository.kt | 16 +- .../tv/data/repository/WatchlistRepository.kt | 33 +-- .../com/arflix/tv/network/OkHttpProvider.kt | 6 +- .../arflix/tv/ui/components/CardLayoutMode.kt | 6 +- .../ui/components/StreamSourceAttribution.kt | 34 +-- .../screens/details/AutoPlaySourcePlanner.kt | 10 +- .../tv/ui/screens/details/DetailsViewModel.kt | 29 ++- .../tv/ui/screens/home/HomeViewModel.kt | 13 +- .../tv/ui/screens/login/LoginViewModel.kt | 28 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 14 +- .../screens/watchlist/WatchlistViewModel.kt | 42 +-- 21 files changed, 517 insertions(+), 279 deletions(-) diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 9fe50114c..afbaeedc5 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" @@ -39,10 +39,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" @@ -60,19 +60,19 @@ jobs: KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} run: | - : "${APP_ANON_KEY:?Missing APP_ANON_KEY repository secret}" - : "${SUPABASE_URL:?Missing SUPABASE_URL repository secret}" - case "$SUPABASE_URL" in - http://*|https://*) ;; - *) echo "SUPABASE_URL must be an HTTP(S) URL" >&2; exit 1 ;; - esac - : "${TMDB_API_KEY:?Missing TMDB_API_KEY repository secret}" - : "${TRAKT_CLIENT_ID:?Missing TRAKT_CLIENT_ID repository secret}" - : "${TRAKT_CLIENT_SECRET:?Missing TRAKT_CLIENT_SECRET repository secret}" - : "${KEYSTORE_BASE64:?Missing KEYSTORE_BASE64 repository secret}" - : "${KEYSTORE_PASSWORD:?Missing KEYSTORE_PASSWORD repository secret}" - : "${KEY_ALIAS:?Missing KEY_ALIAS repository secret}" - : "${KEY_PASSWORD:?Missing KEY_PASSWORD repository secret}" + # : "${APP_ANON_KEY:?Missing APP_ANON_KEY repository secret}" + # : "${SUPABASE_URL:?Missing SUPABASE_URL repository secret}" + # case "$SUPABASE_URL" in + # http://*|https://*) ;; + # *) echo "SUPABASE_URL must be an HTTP(S) URL" >&2; # exit 1 ;; + # esac + # : "${TMDB_API_KEY:?Missing TMDB_API_KEY repository secret}" + # : "${TRAKT_CLIENT_ID:?Missing TRAKT_CLIENT_ID repository secret}" + # : "${TRAKT_CLIENT_SECRET:?Missing TRAKT_CLIENT_SECRET repository secret}" + # : "${KEYSTORE_BASE64:?Missing KEYSTORE_BASE64 repository secret}" + # : "${KEYSTORE_PASSWORD:?Missing KEYSTORE_PASSWORD repository secret}" + # : "${KEY_ALIAS:?Missing KEY_ALIAS repository secret}" + # : "${KEY_PASSWORD:?Missing KEY_PASSWORD repository secret}" - name: Prepare runtime configuration env: diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 70161097f..37dced2e7 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 811745d9a..e08e6b520 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt index 3bd06f6e4..74e4985cb 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt @@ -62,6 +62,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import javax.inject.Inject import javax.inject.Provider @@ -225,7 +226,7 @@ private fun accountSyncPayloadsMatch(expected: String, actual: String?): Boolean private fun safePostgrestError(body: String): String { if (body.isBlank()) return "empty response" - val parsed = try { JSONObject(body) } catch (e: org.json.JSONException) { null } + val parsed = try { JSONObject(body) } catch (e: JSONException) { null } return parsed?.optString("message")?.takeIf { it.isNotBlank() } ?: parsed?.optString("error")?.takeIf { it.isNotBlank() } ?: body.take(180) @@ -679,7 +680,7 @@ class AuthRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() - val json = try { JSONObject(body) } catch (e: org.json.JSONException) { null } + val json = try { JSONObject(body) } catch (e: JSONException) { null } if (!response.isSuccessful) { val message = cloudAuthErrorMessage(json, defaultError) throw IllegalStateException(message) @@ -1708,7 +1709,7 @@ class AuthRepository @Inject constructor( private suspend fun saveAccountSyncPayloadToNetlify(payload: String): Result { return try { - val payloadValue = try { JSONObject(payload) } catch (e: org.json.JSONException) { null } ?: payload + val payloadValue = try { JSONObject(payload) } catch (e: JSONException) { null } ?: payload val body = JSONObject() .put("payload", payloadValue) .toString() @@ -1716,7 +1717,7 @@ class AuthRepository @Inject constructor( url = Constants.NETLIFY_ACCOUNT_SYNC_PUSH_URL, body = body ) - val responseJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null } + val responseJson = try { JSONObject(responseBody) } catch (e: JSONException) { null } if (responseJson == null || !responseJson.optBoolean("accepted", false)) { val reason = responseJson?.optString("reason", "invalid_response") ?: "invalid_response" throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason") @@ -1752,7 +1753,7 @@ class AuthRepository @Inject constructor( "Cloud sync upload failed (${response.code}): ${safePostgrestError(responseBody)}" ) } - val rpcJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null } + val rpcJson = try { JSONObject(responseBody) } catch (e: JSONException) { null } if (rpcJson?.optBoolean("accepted", true) == false) { val reason = rpcJson.optString("reason", "existing_snapshot_is_richer") throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason") @@ -2145,7 +2146,7 @@ class AuthRepository @Inject constructor( val root = if (existingPayload.isBlank()) { JSONObject() } else { - try { JSONObject(existingPayload) } catch (e: org.json.JSONException) { JSONObject() } + try { JSONObject(existingPayload) } catch (e: JSONException) { JSONObject() } } root.put("version", root.optInt("version", 1)) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index 9bf6bef32..d450d7ba1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import kotlin.math.max import javax.inject.Inject @@ -484,7 +485,20 @@ class CloudSyncRepository @Inject constructor( private suspend fun loadJsonMap(key: androidx.datastore.preferences.core.Preferences.Key): JSONObject { val raw = context.settingsDataStore.data.first()[key] - return runCatching { if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw) }.getOrDefault(JSONObject()) + return try { + if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException( + throwable = e, + context = mapOf( + "error_area" to "CloudSync", + "cloud_flow" to "load_json_map", + "pref_key" to key.name + ) + ) + JSONObject() + } } /** @@ -551,11 +565,11 @@ class CloudSyncRepository @Inject constructor( * other=local (never let an older remote value overwrite a newer-unpushed local one). */ private fun mergeSettingsByTimestamp(baseStr: String, otherStr: String): SettingsMergeResult { - val base = try { JSONObject(baseStr) } catch (e: org.json.JSONException) { null } ?: return SettingsMergeResult(baseStr, emptySet()) - val other = try { JSONObject(otherStr) } catch (e: org.json.JSONException) { null } ?: return SettingsMergeResult(baseStr, emptySet()) + val base = try { JSONObject(baseStr) } catch (e: JSONException) { null } ?: return SettingsMergeResult(baseStr, emptySet()) + val other = try { JSONObject(otherStr) } catch (e: JSONException) { null } ?: return SettingsMergeResult(baseStr, emptySet()) val baseTs = base.optJSONObject("fieldUpdatedAt") ?: JSONObject() val otherTs = other.optJSONObject("fieldUpdatedAt") ?: JSONObject() - val mergedTs = try { JSONObject(baseTs.toString()) } catch (e: org.json.JSONException) { JSONObject() } + val mergedTs = try { JSONObject(baseTs.toString()) } catch (e: JSONException) { JSONObject() } val otherWon = HashSet() val allKeys = LinkedHashSet().apply { addAll(mergeKeysOf(base)); addAll(mergeKeysOf(other)) } for (key in allKeys) { @@ -677,9 +691,9 @@ class CloudSyncRepository @Inject constructor( .getOrNull() ?.takeIf { it.isNotBlank() } ?.let { payload -> - runCatching { + try { JSONObject(payload).optJSONObject("profileAvatarImagesById") - }.getOrNull() + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; null } } root.put( "profileAvatarImagesById", @@ -689,7 +703,11 @@ class CloudSyncRepository @Inject constructor( // Validate active Trakt auth before exporting so revoked tokens do not // get written back to cloud and restored on the next launch. - runCatching { traktRepository.hasTrakt() } + try { + traktRepository.hasTrakt() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + } // Trakt tokens per profile val traktTokens = traktRepository.exportTokensForProfiles(profiles.map { it.id }) @@ -803,13 +821,15 @@ class CloudSyncRepository @Inject constructor( root.put("iptvFavoriteChannels", JSONArray(gson.toJson(iptvRepository.observeFavoriteChannels().first()))) // Plugin repositories and scrapers (sideload flavor) - runCatching { + try { val pluginRepos = pluginDataStore.repositories.first() val pluginScrapers = pluginDataStore.scrapers.first() val pluginsEnabled = pluginDataStore.pluginsEnabled.first() root.put("pluginRepositories", JSONArray(gson.toJson(pluginRepos))) root.put("pluginScrapers", JSONArray(gson.toJson(pluginScrapers))) root.put("pluginsEnabled", pluginsEnabled) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e } // Informational @@ -874,7 +894,10 @@ class CloudSyncRepository @Inject constructor( ) return Result.failure(IllegalStateException("Not logged in")) } - val payload = runCatching { buildCloudSnapshotJson() }.getOrElse { + val payload = try { + buildCloudSnapshotJson() + } catch (it: Throwable) { + if (it is kotlinx.coroutines.CancellationException) throw it markPushFailedDirty() pushFailureCount++ AppLogger.recordException( @@ -907,25 +930,22 @@ class CloudSyncRepository @Inject constructor( message = "push_blocked_remote_richer local_profiles=$localProfileCount remote_profiles=$remoteProfileCount", severity = "warning" ) - return runCatching { + return try { invalidationBus.suppressDuringRemoteApply { clearStaleLocalDirtyBeforeRemoteRestore() applyCloudPayload(existingRemotePayload) } markCloudPayloadApplied(existingRemotePayload, existingRemotePayload.hashCode()) clearLocalDirtyAfterSuccessfulPush() - }.fold( - onSuccess = { - Log.i(TAG, "Restored richer remote snapshot before push") - Result.success(Unit) - }, - onFailure = { error -> - markPushFailedDirty() - pushFailureCount++ - Log.w(TAG, "Failed to restore richer remote snapshot before push: ${error.message}", error) - Result.failure(error) - } - ) + Log.i(TAG, "Restored richer remote snapshot before push") + Result.success(Unit) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + markPushFailedDirty() + pushFailureCount++ + Log.w(TAG, "Failed to restore richer remote snapshot before push: ${error.message}", error) + Result.failure(error) + } } val groupOrderMerged = if (existingRemotePayload != null && !iptvRepository.isGroupOrderLocallyDirty()) { @@ -942,9 +962,9 @@ class CloudSyncRepository @Inject constructor( groupOrderMerged } - val payloadHash = runCatching { + val payloadHash = try { JSONObject(effectivePayload).apply { remove("updatedAt") }.toString().hashCode() - }.getOrNull() + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; null } if (!force && payloadHash != null && payloadHash == lastPushedPayloadHash && !isPushDirty && pushFailureCount == 0) { AppLogger.breadcrumb( @@ -992,11 +1012,11 @@ class CloudSyncRepository @Inject constructor( } private fun mergeRemoteGroupOrder(localPayload: String, remotePayload: String): String { - return runCatching { + return try { val local = JSONObject(localPayload) val remote = JSONObject(remotePayload) - val localByProfile = local.optJSONObject("iptvByProfile") ?: return@runCatching localPayload - val remoteByProfile = remote.optJSONObject("iptvByProfile") ?: return@runCatching localPayload + val localByProfile = local.optJSONObject("iptvByProfile") ?: return localPayload + val remoteByProfile = remote.optJSONObject("iptvByProfile") ?: return localPayload val remoteKeys = remoteByProfile.keys() while (remoteKeys.hasNext()) { val profileId = remoteKeys.next() @@ -1010,7 +1030,7 @@ class CloudSyncRepository @Inject constructor( } } local.toString() - }.getOrDefault(localPayload) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; localPayload } } // ══════════════════════════════════════════════════════════ @@ -1072,9 +1092,9 @@ class CloudSyncRepository @Inject constructor( } val remoteRestoreRank = accountSyncPayloadRestoreRank(payload) - val localRestoreRank = runCatching { + val localRestoreRank = try { accountSyncPayloadRestoreRank(buildCloudSnapshotJson()) - }.getOrDefault(0) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; 0 } if (localRestoreRank > remoteRestoreRank && remoteRestoreRank <= 10) { AppLogger.breadcrumb( tag = "CloudSync", @@ -1142,7 +1162,7 @@ class CloudSyncRepository @Inject constructor( return@withLock RestoreResult.RESTORED } - runCatching { + try { invalidationBus.suppressDuringRemoteApply { if (!pushPendingLocalFirst) { clearStaleLocalDirtyBeforeRemoteRestore() @@ -1150,30 +1170,27 @@ class CloudSyncRepository @Inject constructor( applyCloudPayload(payload) } markCloudPayloadApplied(payload, payloadHash) - }.fold( - onSuccess = { - Log.i(TAG, "Pull restored size=${payloadSizeBucket(payload)}") - AppLogger.breadcrumb( - tag = "CloudSync", - message = "pull_restored size=${payloadSizeBucket(payload)}", - severity = "info" - ) - RestoreResult.RESTORED - }, - onFailure = { e -> - Log.w(TAG, "Pull failed size=${payloadSizeBucket(payload)} error=${e.message}") - System.err.println("[CLOUD-SYNC] pullFromCloud failed: ${e.message}") - AppLogger.recordException( - throwable = e, - context = mapOf( - "error_area" to "CloudSync", - "cloud_flow" to "pull_apply_payload", - "payload_size" to payloadSizeBucket(payload) - ) + Log.i(TAG, "Pull restored size=${payloadSizeBucket(payload)}") + AppLogger.breadcrumb( + tag = "CloudSync", + message = "pull_restored size=${payloadSizeBucket(payload)}", + severity = "info" + ) + RestoreResult.RESTORED + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "Pull failed size=${payloadSizeBucket(payload)} error=${e.message}") + System.err.println("[CLOUD-SYNC] pullFromCloud failed: ${e.message}") + AppLogger.recordException( + throwable = e, + context = mapOf( + "error_area" to "CloudSync", + "cloud_flow" to "pull_apply_payload", + "payload_size" to payloadSizeBucket(payload) ) - RestoreResult.FAILED - } - ) + ) + RestoreResult.FAILED + } } /** @@ -1186,11 +1203,11 @@ class CloudSyncRepository @Inject constructor( // below). Skipped when the remote predates this feature (no `fieldUpdatedAt`) so rollout // behaves exactly like today until every device is on the new code. The rest of this // function then writes the merged values exactly as before. - val incomingHasFieldTs = runCatching { + val incomingHasFieldTs = try { JSONObject(payload).optJSONObject("fieldUpdatedAt") != null - }.getOrDefault(false) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } val localSnapshotForMerge = if (incomingHasFieldTs) { - runCatching { buildCloudSnapshotJson() }.getOrNull() + try { buildCloudSnapshotJson() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; null } } else { null } @@ -1471,16 +1488,19 @@ class CloudSyncRepository @Inject constructor( authRepository.saveAutoPlayNextToProfile(fallbackAutoPlayNext) // ── Trakt tokens ── - runCatching { + try { root.optJSONObject("traktTokens")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TraktRepository.CloudTraktToken::class.java).type val tokens: Map = gson.fromJson(json, type) ?: emptyMap() traktRepository.importTokensForProfiles(tokens) } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_trakt_tokens")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_trakt_tokens")) + } // ── MDBList selection (provider + API key) ── - runCatching { + try { root.optJSONObject("mdbListSyncByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized( Map::class.java, @@ -1491,10 +1511,13 @@ class CloudSyncRepository @Inject constructor( gson.fromJson(json, type) ?: emptyMap() syncProviderStore.importForProfiles(map) } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_mdblist_sync")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_mdblist_sync")) + } // ── Addons ── - runCatching { + try { val cloudAddonsTs = root.optLong("addonsUpdatedAt", 0L) val localAddonsTs = streamRepository.getAddonsUpdatedAt() var appliedCloudAddons = false @@ -1525,10 +1548,13 @@ class CloudSyncRepository @Inject constructor( if (appliedCloudAddons && cloudAddonsTs > localAddonsTs) { streamRepository.setAddonsUpdatedAt(cloudAddonsTs) } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_addons")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_addons")) + } // ── Catalogs ── - runCatching { + try { root.optJSONObject("catalogsByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TypeToken.getParameterized(List::class.java, CatalogConfig::class.java).type).type val map: Map> = gson.fromJson(json, type) ?: emptyMap() @@ -1545,10 +1571,13 @@ class CloudSyncRepository @Inject constructor( } } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_catalogs")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_catalogs")) + } // ── Hidden preinstalled catalogs ── - runCatching { + try { root.optJSONObject("hiddenPreinstalledByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TypeToken.getParameterized(List::class.java, String::class.java).type).type val map: Map> = gson.fromJson(json, type) ?: emptyMap() @@ -1567,10 +1596,13 @@ class CloudSyncRepository @Inject constructor( catalogRepository.setHiddenPreinstalledCatalogIdsForProfile(activeProfileId, hidden) } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_hidden_preinstalled")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_hidden_preinstalled")) + } // ── Hidden addon catalogs ── - runCatching { + try { root.optJSONObject("hiddenAddonByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TypeToken.getParameterized(List::class.java, String::class.java).type).type val map: Map> = gson.fromJson(json, type) ?: emptyMap() @@ -1578,10 +1610,13 @@ class CloudSyncRepository @Inject constructor( catalogRepository.setHiddenAddonCatalogIdsForProfile(profileId, hidden) } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_hidden_addons")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_hidden_addons")) + } // ── Hidden Home Server catalogs ── - runCatching { + try { root.optJSONObject("hiddenHomeServerByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TypeToken.getParameterized(List::class.java, String::class.java).type).type val map: Map> = gson.fromJson(json, type) ?: emptyMap() @@ -1589,10 +1624,13 @@ class CloudSyncRepository @Inject constructor( catalogRepository.setHiddenHomeServerCatalogIdsForProfile(profileId, hidden) } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_hidden_home_server")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_hidden_home_server")) + } // ── IPTV config + favorites ── - runCatching { + try { var importedActiveProfileIptv = false root.optJSONObject("iptvByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, IptvCloudProfileState::class.java).type @@ -1632,14 +1670,19 @@ class CloudSyncRepository @Inject constructor( } if (importedActiveProfileIptv || importedLegacyIptv) { - runCatching { + try { iptvRepository.invalidateCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_iptv")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_iptv")) + } // ── Watchlist ── - runCatching { + try { root.optJSONObject("watchlistByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TypeToken.getParameterized(List::class.java, LocalWatchlistItem::class.java).type).type val map: Map> = gson.fromJson(json, type) ?: emptyMap() @@ -1651,19 +1694,25 @@ class CloudSyncRepository @Inject constructor( watchlistRepository.importWatchlistForProfile(profileId, items) } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_watchlist")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_watchlist")) + } // ── Dismissed Continue Watching ── - runCatching { + try { root.optJSONObject("dismissedContinueWatchingByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, String::class.java).type val map: Map = gson.fromJson(json, type) ?: emptyMap() traktRepository.importDismissedContinueWatchingForProfiles(map) } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_dismissed_cw")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_dismissed_cw")) + } // ── Local Continue Watching ── - runCatching { + try { // Only import local CW for profiles that DON'T have Trakt connected. // For Trakt profiles, CW is sourced exclusively from Trakt's progress API. root.optJSONObject("localContinueWatchingByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> @@ -1676,9 +1725,12 @@ class CloudSyncRepository @Inject constructor( ?.toString() ?.takeIf { it.isNotBlank() } ?.let { tokenJson -> - runCatching { + try { gson.fromJson>(tokenJson, traktTokenType) - }.getOrNull() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } } .orEmpty() @@ -1688,7 +1740,12 @@ class CloudSyncRepository @Inject constructor( } } - val isActiveProfileTrakt = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) + val isActiveProfileTrakt = try { + traktRepository.isAuthenticated.first() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + false + } val activeProfileIdLocal = profileManager.getProfileIdSync().ifBlank { null } if (isActiveProfileTrakt && activeProfileIdLocal != null) { traktProfiles.add(activeProfileIdLocal) @@ -1699,9 +1756,12 @@ class CloudSyncRepository @Inject constructor( traktRepository.importLocalContinueWatchingForProfiles(nonTraktOnly) } } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_local_cw")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_local_cw")) + } - runCatching { + try { root.optJSONObject("localWatchedMoviesByProfile")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = TypeToken.getParameterized(Map::class.java, String::class.java, TypeToken.getParameterized(List::class.java, Int::class.javaObjectType).type).type val map: Map> = gson.fromJson(json, type) ?: emptyMap() @@ -1713,13 +1773,16 @@ class CloudSyncRepository @Inject constructor( val map: Map> = gson.fromJson(json, type) ?: emptyMap() traktRepository.importLocalWatchedEpisodesForProfiles(map) } - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_local_watched")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_local_watched")) + } traktRepository.clearAllProfileCaches() watchHistoryRepository.clearProfileCaches() // Restore plugin repositories and scrapers - runCatching { + try { root.optJSONArray("pluginRepositories")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, com.arflix.tv.domain.model.PluginRepository::class.java).type val repos: List = gson.fromJson(json, type) ?: emptyList() @@ -1731,7 +1794,10 @@ class CloudSyncRepository @Inject constructor( if (scrapers.isNotEmpty()) pluginDataStore.saveScrapers(scrapers) } if (root.has("pluginsEnabled")) pluginDataStore.setPluginsEnabled(root.optBoolean("pluginsEnabled", false)) - }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_plugins")) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_plugins")) + } // Reset the per-field baseline/timestamps to the merged result so the next snapshot build // does not see remote-applied values as fresh local changes (ping-pong guard). If we diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt index 104e1d937..87784ed52 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt @@ -17,6 +17,7 @@ import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.reflect.TypeToken import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -334,7 +335,7 @@ class HomeServerRepository @Inject constructor( displayName: String = "" ): Result = withContext(Dispatchers.IO) { - runCatching { + try { val serverUrl = normalizeServerUrl(rawUrl) val trimmedUsername = username.trim() val trimmedDisplayName = displayName.trim() @@ -354,7 +355,7 @@ class HomeServerRepository @Inject constructor( displayName = trimmedDisplayName ) saveConnection(connection) - return@runCatching connection + return@withContext Result.success(connection) } require(trimmedUsername.isNotBlank()) { context.getString(R.string.homeserver_enter_username) } @@ -375,7 +376,10 @@ class HomeServerRepository @Inject constructor( ) val connection = connectionShell.copy(collections = fetchCollections(connectionShell)) saveConnection(connection) - connection + Result.success(connection) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) } } @@ -385,7 +389,7 @@ class HomeServerRepository @Inject constructor( displayName: String = "" ): Result = withContext(Dispatchers.IO) { - runCatching { + try { val connection = buildPlexConnection( accountToken = accountToken, preferredServerUrl = preferredServerUrl, @@ -394,7 +398,10 @@ class HomeServerRepository @Inject constructor( displayName = displayName.trim() ) saveConnection(connection) - connection + Result.success(connection) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) } } @@ -403,12 +410,15 @@ class HomeServerRepository @Inject constructor( } suspend fun testConnections(): Result> = withContext(Dispatchers.IO) { - runCatching { + try { val current = currentConnections() require(current.isNotEmpty()) { context.getString(R.string.homeserver_none_connected) } val refreshed = current.map { refreshConnection(it) } saveConnections(refreshed) - refreshed + Result.success(refreshed) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) } } @@ -421,25 +431,34 @@ class HomeServerRepository @Inject constructor( } suspend fun startHomeServerCodeAuth(serverUrl: String): Result = withContext(Dispatchers.IO) { - runCatching { + try { val normalizedUrl = normalizeServerUrl(serverUrl) - if (normalizedUrl.isBlank()) return@runCatching startPlexPinAuthInternal() + if (normalizedUrl.isBlank()) return@withContext Result.success(startPlexPinAuthInternal()) val publicInfo = fetchPublicInfo(normalizedUrl) val detectedKind = publicInfo.serverKind .takeUnless { it == HomeServerKind.UNKNOWN } ?: detectServerKind(publicInfo.productName, publicInfo.serverName) - when (detectedKind) { + val session = when (detectedKind) { HomeServerKind.JELLYFIN -> startJellyfinQuickConnect(normalizedUrl) HomeServerKind.PLEX -> startPlexPinAuthInternal().copy(serverUrl = normalizedUrl) HomeServerKind.EMBY -> error("Code sign in is not supported by Emby. Use username and password.") HomeServerKind.UNKNOWN -> error("Could not detect this server. Use username and password, or leave URL empty for Plex.") } + Result.success(session) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) } } suspend fun startPlexPinAuth(): Result = withContext(Dispatchers.IO) { - runCatching { startPlexPinAuthInternal() } + try { + Result.success(startPlexPinAuthInternal()) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) + } } private fun startPlexPinAuthInternal(): PlexPinAuthSession { @@ -496,7 +515,7 @@ class HomeServerRepository @Inject constructor( } suspend fun pollPlexPinAuth(pinId: String): Result = withContext(Dispatchers.IO) { - runCatching { + try { val url = "https://plex.tv/api/v2/pins/$pinId".toHttpUrlOrNull() ?.newBuilder() ?.addQueryParameter("X-Plex-Client-Identifier", deviceId()) @@ -508,7 +527,7 @@ class HomeServerRepository @Inject constructor( .get() .headers(plexPublicHeaders()) .build() - okHttpClient.newCall(request).execute().use { response -> + val token = okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { error(context.getString(R.string.homeserver_code_poll_failed, response.code)) @@ -518,6 +537,10 @@ class HomeServerRepository @Inject constructor( .ifBlank { json.string("auth_token") } .takeIf { it.isNotBlank() } } + Result.success(token) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) } } @@ -526,8 +549,8 @@ class HomeServerRepository @Inject constructor( preferredServerUrl: String = "", displayName: String = "" ): Result = withContext(Dispatchers.IO) { - runCatching { - when (session.serverKind) { + try { + val conn = when (session.serverKind) { HomeServerKind.PLEX -> { val token = pollPlexPinAuth(session.id).getOrThrow() if (token.isNullOrBlank()) { @@ -543,6 +566,10 @@ class HomeServerRepository @Inject constructor( HomeServerKind.JELLYFIN -> pollJellyfinQuickConnect(session, displayName) else -> error(context.getString(R.string.homeserver_code_signin_failed)) } + Result.success(conn) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Result.failure(e) } } @@ -596,9 +623,9 @@ class HomeServerRepository @Inject constructor( val libraryCandidates = connection.collections .filter { it.enabled && it.id.isNotBlank() } .map { collection -> connection.toCatalogCandidate(collection) } - val serverCollectionCandidates = runCatching { + val serverCollectionCandidates = try { fetchServerCollectionCatalogs(connection) - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } libraryCandidates + serverCollectionCandidates } .distinctBy { it.sourceRef } @@ -623,8 +650,16 @@ class HomeServerRepository @Inject constructor( val loader = { loadConnectionCatalogItems(connection, collectionId, collectionType, offset, limit, sort, mediaType, searchQuery) } - if (propagateErrors) loader() else runCatching(loader) - .getOrDefault(HomeServerCatalogPage(emptyList(), hasMore = false)) + if (propagateErrors) { + loader() + } else { + try { + loader() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + HomeServerCatalogPage(emptyList(), hasMore = false) + } + } } suspend fun resolveMovieSources( @@ -650,14 +685,14 @@ class HomeServerRepository @Inject constructor( coroutineScope { connections.map { connection -> async { - runCatching { + try { val items = findMovieMatches(connection, imdbId, title, year, tmdbId) coroutineScope { items.map { item -> async { buildStreamSources(connection, item) } } .awaitAll() .flatten() } - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } }.awaitAll() .flatten() @@ -691,9 +726,9 @@ class HomeServerRepository @Inject constructor( coroutineScope { connections.map { connection -> async { - runCatching { + try { val series = findBestSeries(connection, imdbId, title, null, tmdbId, tvdbId) - ?: return@runCatching emptyList() + ?: return@async emptyList() val episodeItems = findEpisodes(connection, series.id, season, episode) .ifEmpty { listOfNotNull( @@ -713,7 +748,7 @@ class HomeServerRepository @Inject constructor( .awaitAll() .flatten() } - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } }.awaitAll() .flatten() @@ -1198,14 +1233,14 @@ class HomeServerRepository @Inject constructor( .header("Accept", "application/json") .header("X-Plex-Token", token) .build() - return runCatching { + return try { okHttpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use "" val body = response.body?.string().orEmpty() val json = JsonParser().parse(body).asJsonObjectOrNull() ?: return@use "" json.string("friendlyName").ifBlank { json.string("username") }.ifBlank { json.string("title") } } - }.getOrDefault("") + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; "" } } private fun buildPlexConnection( @@ -1222,7 +1257,7 @@ class HomeServerRepository @Inject constructor( val normalizedPreferredUrl = normalizeServerUrl(preferredServerUrl) val preferredIdentity = preferredInfo?.takeIf { it.serverId.isNotBlank() } ?: normalizedPreferredUrl.takeIf { it.isNotBlank() }?.let { url -> - runCatching { + try { fetchSystemInfo( HomeServerConnection( serverUrl = url, @@ -1233,7 +1268,7 @@ class HomeServerRepository @Inject constructor( userName = preferredUsername.ifBlank { "Account" } ) ) - }.getOrNull() + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; null } } val accountName = validatePlexAccount(trimmedAccountToken) .ifBlank { preferredUsername.ifBlank { "Account" } } @@ -1321,12 +1356,12 @@ class HomeServerRepository @Inject constructor( .header("Accept", "application/xml") .header("X-Plex-Token", accountToken) .build() - return runCatching { + return try { okHttpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use emptyList() parsePlexResourcesXml(response.body?.string().orEmpty()) } - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } private fun parsePlexResourcesXml(xml: String): List { @@ -1527,7 +1562,7 @@ class HomeServerRepository @Inject constructor( return connection.collections .filter { it.enabled && it.id.isNotBlank() } .flatMap { library -> - runCatching { + try { getJson( buildUrl( connection.serverUrl, @@ -1550,7 +1585,7 @@ class HomeServerRepository @Inject constructor( ) connection.toCatalogCandidate(collection) } - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } } @@ -1709,13 +1744,13 @@ class HomeServerRepository @Inject constructor( coroutineScope { providerQueries(imdbId, tmdbId, null).map { providerId -> async { - runCatching { + try { queryItems( connection, itemTypes = "Movie", query = mapOf("AnyProviderIdEquals" to providerId, "Limit" to "10") ) - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } }.awaitAll().flatten().forEach { candidates[it.id] = it } } @@ -1766,13 +1801,13 @@ class HomeServerRepository @Inject constructor( coroutineScope { providerQueries(imdbId, tmdbId, tvdbId).map { providerId -> async { - runCatching { + try { queryItems( connection, itemTypes = "Series", query = mapOf("AnyProviderIdEquals" to providerId, "Limit" to "10") ) - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } }.awaitAll().flatten().forEach { candidates[it.id] = it } } @@ -1962,7 +1997,7 @@ class HomeServerRepository @Inject constructor( val sectionResults = if (collections.isNotEmpty()) { collections.flatMap { collection -> - runCatching { + try { getJson( buildUrl( connection.serverUrl, @@ -1976,7 +2011,7 @@ class HomeServerRepository @Inject constructor( ), connection ).metadataItems(connection.serverKind) - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } } else { emptyList() @@ -1999,7 +2034,7 @@ class HomeServerRepository @Inject constructor( connection.collections.filter { it.enabled } } return targetCollections.flatMap { collection -> - runCatching { + try { getJson( buildUrl( connection.serverUrl, @@ -2013,7 +2048,7 @@ class HomeServerRepository @Inject constructor( ), connection ).metadataItems(connection.serverKind) - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } } @@ -2023,7 +2058,7 @@ class HomeServerRepository @Inject constructor( plexType: String?, limit: String ): List { - return runCatching { + return try { getJson( buildUrl( connection.serverUrl, @@ -2037,7 +2072,7 @@ class HomeServerRepository @Inject constructor( ), connection ).metadataItems(connection.serverKind) - }.getOrDefault(emptyList()) + } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; emptyList() } } private fun filterPlexEpisodeNumbers( @@ -2102,7 +2137,7 @@ class HomeServerRepository @Inject constructor( item: HomeServerItem ): List { val sources = if (connection.serverKind == HomeServerKind.PLEX) { - val refreshedSources = runCatching { + val refreshedSources = try { getJson( buildUrl( connection.serverUrl, @@ -2114,11 +2149,14 @@ class HomeServerRepository @Inject constructor( ), connection ).metadataItems(connection.serverKind).firstOrNull()?.mediaSources.orEmpty() - }.getOrDefault(emptyList()) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptyList() + } (refreshedSources + item.mediaSources) .distinctBy { it.identityKey() } } else { - val playbackInfoSources = runCatching { + val playbackInfoSources = try { postJson( buildUrl( connection.serverUrl, @@ -2134,7 +2172,10 @@ class HomeServerRepository @Inject constructor( JsonObject(), connection ).mediaSources() - }.getOrDefault(emptyList()) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptyList() + } (playbackInfoSources + item.mediaSources) .distinctBy { it.identityKey() } } @@ -2571,10 +2612,16 @@ class HomeServerRepository @Inject constructor( .replace(">", ">") private fun parsePlexIdentity(body: String): Pair { - val container = runCatching { + val container = try { val json = JsonParser().parse(body).asJsonObjectOrNull() json?.obj("MediaContainer") ?: json - }.getOrNull() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: com.google.gson.JsonSyntaxException) { + null + } catch (e: IllegalStateException) { + null + } val name = container?.string("friendlyName").orEmpty() val id = container?.string("machineIdentifier").orEmpty() if (name.isNotBlank() || id.isNotBlank()) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt index de9bf2c41..7ec303fa8 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/HttpLocalScraperRuntime.kt @@ -347,17 +347,17 @@ class HttpLocalScraperRuntime @Inject constructor( mediaType: String, season: Int?, episode: Int? - ): List = runCatching { + ): List = try { val encrypted = getJson("https://enc-dec.app/api/enc-vidlink?text=${tmdbId.toString().urlEncode()}") ?.string("result") - ?: return@runCatching emptyList() + ?: return emptyList() val url = if (mediaType == "tv") { "https://vidlink.pro/api/b/tv/$encrypted/${season ?: 1}/${episode ?: 1}?multiLang=0" } else { "https://vidlink.pro/api/b/movie/$encrypted?multiLang=0" } - val payload = getJson(url, VIDLINK_HEADERS) ?: return@runCatching emptyList() - val playlist = payload.getObject("stream")?.string("playlist") ?: return@runCatching emptyList() + val payload = getJson(url, VIDLINK_HEADERS) ?: return emptyList() + val playlist = payload.getObject("stream")?.string("playlist") ?: return emptyList() listOf( HttpResolvedStream( provider = "VidLink", @@ -367,7 +367,10 @@ class HttpLocalScraperRuntime @Inject constructor( headers = mapOf("Referer" to "https://vidlink.pro/", "Origin" to "https://vidlink.pro") ) ) - }.getOrDefault(emptyList()) + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptyList() + } private suspend fun resolveRgShows( tmdbId: Int, @@ -499,9 +502,9 @@ class HttpLocalScraperRuntime @Inject constructor( episode: Int?, fallbackTitle: String, fallbackYear: Int? - ): List = runCatching { + ): List = try { val details = fetchTmdbDetails(tmdbId, mediaType, fallbackTitle, fallbackYear) - val cookie = netMirrorCookie() ?: return@runCatching emptyList() + val cookie = netMirrorCookie() ?: return emptyList() val cookies = "t_hash_t=$cookie; hd=on" val platforms = listOf( NetMirrorPlatform("netflix", "nf", "/mobile/search.php", "/mobile/post.php", "/mobile/episodes.php", "/mobile/playlist.php"), @@ -509,12 +512,19 @@ class HttpLocalScraperRuntime @Inject constructor( NetMirrorPlatform("hotstar", "hs", "/mobile/hs/search.php", "/mobile/hs/post.php", "/mobile/hs/episodes.php", "/mobile/hs/playlist.php"), NetMirrorPlatform("disney", "hs", "/mobile/hs/search.php", "/mobile/hs/post.php", "/mobile/hs/episodes.php", "/mobile/hs/playlist.php") ) + var resultStreams = emptyList() for (platform in platforms) { val streams = fetchNetMirrorPlatform(platform, details.title, mediaType, season, episode, cookies) - if (streams.isNotEmpty()) return@runCatching streams + if (streams.isNotEmpty()) { + resultStreams = streams + break + } } + resultStreams + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e emptyList() - }.getOrDefault(emptyList()) + } private suspend fun resolveVidSrc( tmdbId: Int, @@ -707,7 +717,7 @@ class HttpLocalScraperRuntime @Inject constructor( fallbackTitle: String, fallbackYear: Int? ): HttpScraperTmdbDetails { - return runCatching { + return try { val type = if (mediaType == "tv") "tv" else "movie" val payload = getJson( "https://api.themoviedb.org/3/$type/$tmdbId?api_key=${Constants.TMDB_API_KEY}&append_to_response=external_ids" @@ -719,7 +729,8 @@ class HttpLocalScraperRuntime @Inject constructor( val imdbId = payload?.getObject("external_ids")?.string("imdb_id") ?: payload?.string("imdb_id") HttpScraperTmdbDetails(tmdbId.toString(), title, year, imdbId, type) - }.getOrElse { + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e HttpScraperTmdbDetails(tmdbId.toString(), fallbackTitle, fallbackYear?.toString(), null, mediaType) } } @@ -730,10 +741,13 @@ class HttpLocalScraperRuntime @Inject constructor( synchronized(tmdbIdCache) { if (tmdbIdCache.containsKey(key)) return tmdbIdCache[key] } - val resolved = runCatching { + val resolved = try { val find = tmdbApi.findByExternalId(clean, Constants.TMDB_API_KEY) if (mediaType == "tv") find.tvResults.firstOrNull()?.id else find.movieResults.firstOrNull()?.id - }.getOrNull() + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } synchronized(tmdbIdCache) { tmdbIdCache[key] = resolved } return resolved } @@ -742,10 +756,13 @@ class HttpLocalScraperRuntime @Inject constructor( synchronized(manifestCache) { manifestCache[manifestUrl]?.let { return it } } - val parsed = runCatching { + val parsed = try { val json = getText(manifestUrl) gson.fromJson(json, HttpScraperManifest::class.java) - }.getOrNull()?.takeIf { it.name.isNotBlank() && it.scrapers.isNotEmpty() } + } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + }?.takeIf { it.name.isNotBlank() && it.scrapers.isNotEmpty() } if (parsed != null) { synchronized(manifestCache) { manifestCache[manifestUrl] = parsed } } @@ -769,11 +786,31 @@ class HttpLocalScraperRuntime @Inject constructor( } private suspend fun getJson(url: String, headers: Map = emptyMap()): JsonObject? { - return runCatching { gson.fromJson(getText(url, headers), JsonObject::class.java) }.getOrNull() + return try { + gson.fromJson(getText(url, headers), JsonObject::class.java) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: com.google.gson.JsonSyntaxException) { + null + } catch (e: IllegalStateException) { + null + } catch (e: java.io.IOException) { + null + } } private suspend fun getJsonElement(url: String, headers: Map = emptyMap()): JsonElement? { - return runCatching { gson.fromJson(getText(url, headers), JsonElement::class.java) }.getOrNull() + return try { + gson.fromJson(getText(url, headers), JsonElement::class.java) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: com.google.gson.JsonSyntaxException) { + null + } catch (e: IllegalStateException) { + null + } catch (e: java.io.IOException) { + null + } } private suspend fun resolveRedirectUrl(url: String, headers: Map): String? = withContext(Dispatchers.IO) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt index 51679d04b..6c107243f 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvPlaybackUrlResolver.kt @@ -119,22 +119,30 @@ internal class IptvPlaybackUrlResolver( } } catch (e: kotlinx.coroutines.CancellationException) { throw e + } catch (e: java.io.IOException) { + null + } catch (e: java.lang.IllegalArgumentException) { + null } catch (e: Exception) { null } } + + private fun looksLikeHlsPlaybackUrl(url: String): Boolean { + val clean = url.substringBefore('?').substringBefore('#') + return clean.endsWith(".m3u8", ignoreCase = true) + } } internal fun shouldResolveIptvPlaybackRedirect(url: String): Boolean { val trimmed = url.trim() - if (!trimmed.startsWith("http://", ignoreCase = true) && - !trimmed.startsWith("https://", ignoreCase = true) - ) { + if (trimmed.isBlank()) return false + if (!trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) { return false } if (looksLikeHlsPlaybackUrl(trimmed)) return false - val uri = try { URI(trimmed) } catch (e: Exception) { null } ?: return false + val uri = try { URI(trimmed) } catch (e: java.net.URISyntaxException) { null } catch (e: java.lang.IllegalArgumentException) { null } ?: return false val path = uri.path.orEmpty().trimEnd('/').lowercase(Locale.US) val lastSegment = path.substringAfterLast('/') if (lastSegment.isBlank() || lastSegment.contains('.')) return false diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 0da2e8249..7a5730c65 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -37,6 +37,7 @@ import com.arflix.tv.util.CatalogUrlParser import com.arflix.tv.util.Constants import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -374,12 +375,16 @@ class MediaRepository @Inject constructor( } private suspend fun resolveExternalIds(mediaType: MediaType, mediaId: Int): TmdbExternalIds? { - return runCatching { + return try { when (mediaType) { MediaType.MOVIE -> tmdbApi.getMovieExternalIds(mediaId, apiKey) MediaType.TV -> tmdbApi.getTvExternalIds(mediaId, apiKey) } - }.getOrNull() + } catch (e: Exception) { + if (e is CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e, mapOf("error_area" to "MediaRepository", "action" to "resolveExternalIds")) + null + } } private suspend fun fetchCinemetaImdbRating(mediaType: MediaType, imdbId: String): String? = withContext(Dispatchers.IO) { @@ -1711,7 +1716,7 @@ class MediaRepository @Inject constructor( calendar.add(Calendar.MONTH, -18) val eighteenMonthsAgo = dateFormat.format(calendar.time) - val response = runCatching { + val response = try { when (categoryId) { "trending_movies" -> tmdbApi.getTrendingMovies(apiKey, language = contentLanguage, page = page) "trending_tv" -> tmdbApi.getTrendingTv(apiKey, language = contentLanguage, page = page) @@ -1729,7 +1734,11 @@ class MediaRepository @Inject constructor( // favor of the Services collection-tile row. else -> null } - }.getOrNull() ?: return CategoryPageResult(emptyList(), hasMore = false) + } catch (e: Exception) { + if (e is CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e, mapOf("error_area" to "MediaRepository", "action" to "loadHomeCategoryPage")) + null + } ?: return CategoryPageResult(emptyList(), hasMore = false) val mediaType = if (categoryId == "trending_movies") MediaType.MOVIE else MediaType.TV val items = response.results diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 1eaa50409..8f93f0334 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -366,7 +367,7 @@ class StreamRepository @Inject constructor( stremioAddonRuntime ).associateBy { it.kind } private val addonRuntimeAggregator = AddonRuntimeAggregator(addonRuntimes) - private data class AddonRuntimeHealth( + internal data class AddonRuntimeHealth( var fetchSuccesses: Int = 0, var fetchFailures: Int = 0, var playbackStarts: Int = 0, @@ -2930,7 +2931,7 @@ class StreamRepository @Inject constructor( tmdbId: Int? = null ) = withContext(Dispatchers.IO) { if (title.isBlank()) return@withContext - runCatching { + try { iptvRepository.prefetchEpisodeVodResolution( title = title, season = season, @@ -2938,6 +2939,9 @@ class StreamRepository @Inject constructor( imdbId = imdbId, tmdbId = tmdbId ) + } catch (e: Exception) { + if (e is CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e, mapOf("error_area" to "StreamRepository", "action" to "prefetchEpisodeVod")) } } @@ -2947,12 +2951,15 @@ class StreamRepository @Inject constructor( tmdbId: Int? = null ) = withContext(Dispatchers.IO) { if (title.isBlank()) return@withContext - runCatching { + try { iptvRepository.prefetchSeriesInfoForShow( title = title, imdbId = imdbId, tmdbId = tmdbId ) + } catch (e: Exception) { + if (e is CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e, mapOf("error_area" to "StreamRepository", "action" to "prefetchSeriesVodInfo")) } } @@ -4105,10 +4112,12 @@ class StreamRepository @Inject constructor( val parsed: Map = if (raw.isBlank()) { emptyMap() } else { - runCatching { - val type = TypeToken.getParameterized(Map::class.java, String::class.java, AddonRuntimeHealth::class.java).type - gson.fromJson>(raw, type) - }.getOrNull().orEmpty() + try { + gson.fromJson>(raw, StreamRepositoryTypeTokens.ADDON_HEALTH_TYPE) ?: emptyMap() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptyMap() + } } synchronized(addonRuntimeHealth) { @@ -4366,3 +4375,7 @@ data class AddonRefreshReport( val refreshed: Int = 0, val failed: Int = 0 ) + +private object StreamRepositoryTypeTokens { + val ADDON_HEALTH_TYPE: java.lang.reflect.Type = com.google.gson.reflect.TypeToken.getParameterized(Map::class.java, String::class.java, StreamRepository.AddonRuntimeHealth::class.java).type +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/WatchHistoryRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/WatchHistoryRepository.kt index 13a0f5c25..2f955cbfd 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/WatchHistoryRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/WatchHistoryRepository.kt @@ -197,7 +197,7 @@ class WatchHistoryRepository @Inject constructor( realtimeSyncManagerProvider.get().markLocalWatchHistoryWrite() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e - com.arflix.tv.util.AppLogger.e("WatchHistoryRepository", "Failed to mark local write", e) + AppLogger.recordException(e, mapOf("error_area" to "WatchHistoryRepository", "watch_history_phase" to "mark_local_write")) } return } @@ -217,9 +217,9 @@ class WatchHistoryRepository @Inject constructor( supabaseApi.upsertWatchHistory(auth = auth, item = fallback.toRecord()) } saved = true - } catch (fallbackEx: Exception) { - if (fallbackEx is kotlinx.coroutines.CancellationException) throw fallbackEx - AppLogger.e("WatchHistoryRepository", "Fallback error in watch history operation", fallbackEx) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException(e, mapOf("error_area" to "WatchHistoryRepository", "watch_history_phase" to "save_fallback")) } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -253,7 +253,7 @@ class WatchHistoryRepository @Inject constructor( realtimeSyncManagerProvider.get().markLocalWatchHistoryWrite() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e - com.arflix.tv.util.AppLogger.e("WatchHistoryRepository", "Failed to mark local write", e) + AppLogger.recordException(e, mapOf("error_area" to "WatchHistoryRepository", "watch_history_phase" to "mark_local_write")) } } } @@ -289,6 +289,7 @@ class WatchHistoryRepository @Inject constructor( cachedWatchHistoryByProfile[profileId] = result result } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Error getting watch history, returning cache", e) cachedWatchHistoryByProfile[profileId].orEmpty() } @@ -339,6 +340,7 @@ class WatchHistoryRepository @Inject constructor( cachedContinueWatchingByProfile[profileId] = result result } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Error getting continue watching, returning cache", e) filterLive(cachedContinueWatchingByProfile[profileId].orEmpty()) } @@ -379,6 +381,7 @@ class WatchHistoryRepository @Inject constructor( } filterByProfile(records.map { it.toEntry() }).firstOrNull() } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Error returning null fallback", e) null } @@ -422,6 +425,7 @@ class WatchHistoryRepository @Inject constructor( parseEpoch(entry.updated_at).coerceAtLeast(parseEpoch(entry.paused_at)) } } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Error returning null fallback", e) null } @@ -466,6 +470,7 @@ class WatchHistoryRepository @Inject constructor( ) } } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Silently handled error", e) } } @@ -494,6 +499,7 @@ class WatchHistoryRepository @Inject constructor( ) } } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Silently handled error", e) } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt index 14b621d9b..2fb993d36 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/WatchlistRepository.kt @@ -376,11 +376,7 @@ class WatchlistRepository @Inject constructor( return try { val prefs = context.traktDataStore.data.first() val json = prefs[watchlistKeyFor(safeProfileId)] ?: return emptyList() - val type = TypeToken.getParameterized( - MutableList::class.java, - LocalWatchlistItem::class.java - ).type - gson.fromJson>(json, type) ?: emptyList() + gson.fromJson>(json, WatchlistRepoTypeTokens.listType) ?: emptyList() } catch (error: kotlinx.coroutines.CancellationException) { throw error } catch (error: Exception) { @@ -416,10 +412,9 @@ class WatchlistRepository @Inject constructor( return } - val type = TypeToken.getParameterized(MutableList::class.java, LocalWatchlistItem::class.java).type val localItems: List = if (localJson != null) { try { - gson.fromJson>(localJson, type) ?: emptyList() + gson.fromJson>(localJson, WatchlistRepoTypeTokens.listType) ?: emptyList() } catch (e: com.google.gson.JsonSyntaxException) { emptyList() } @@ -486,11 +481,7 @@ class WatchlistRepository @Inject constructor( return try { val prefs = context.traktDataStore.data.first() val json = prefs[watchlistKey()] ?: return emptyList() - val type = TypeToken.getParameterized( - MutableList::class.java, - LocalWatchlistItem::class.java - ).type - (gson.fromJson>(json, type) ?: emptyList()) + (gson.fromJson>(json, WatchlistRepoTypeTokens.listType) ?: emptyList()) .sortedWith(compareBy { it.sourceOrder }.thenByDescending { it.addedAt }) } catch (error: kotlinx.coroutines.CancellationException) { throw error @@ -563,13 +554,13 @@ class WatchlistRepository @Inject constructor( private fun parseWatchlistItems(json: String?): List { if (json.isNullOrBlank()) return emptyList() - return runCatching { - val type = TypeToken.getParameterized( - MutableList::class.java, - LocalWatchlistItem::class.java - ).type - gson.fromJson>(json, type).orEmpty() - }.getOrDefault(emptyList()) + return try { + gson.fromJson>(json, WatchlistRepoTypeTokens.listType) ?: emptyList() + } catch (e: com.google.gson.JsonSyntaxException) { + emptyList() + } catch (e: IllegalStateException) { + emptyList() + } } /** @@ -663,3 +654,7 @@ class WatchlistRepository @Inject constructor( } } + +private object WatchlistRepoTypeTokens { + val listType = TypeToken.getParameterized(MutableList::class.java, LocalWatchlistItem::class.java).type +} diff --git a/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt b/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt index 3f2a283bb..da028c9c0 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt @@ -237,9 +237,11 @@ object OkHttpProvider { var bytes = rawBytes repeat(MAX_GZIP_LAYERS) { if (!bytes.hasGzipMagic()) return bytes - bytes = runCatching { + bytes = try { GZIPInputStream(ByteArrayInputStream(bytes)).use { it.readBytes() } - }.getOrNull() ?: return null + } catch (e: java.io.IOException) { + null + } ?: return null } return bytes.takeUnless { it.hasGzipMagic() } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt index 0b1c74028..0b837c020 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/CardLayoutMode.kt @@ -44,7 +44,9 @@ const val CARD_LAYOUT_MODE_POSTER = "Poster" private val cardLayoutModeKey = stringPreferencesKey("card_layout_mode") private val activeProfileIdKey = stringPreferencesKey("active_profile_id") private const val CATALOGUE_ROW_LAYOUT_PREFIX = "catalogue_row_layout_" -private val ALPHANUMERIC_REGEX = Regex("[^a-z0-9_.:-]+") +private object CardLayoutModeRegexes { + val ALPHANUMERIC_REGEX = Regex("[^a-z0-9_.:-]+") +} private fun profileCardLayoutModeKey(profileId: String): Preferences.Key { return stringPreferencesKey("profile_${profileId}_card_layout_mode") @@ -79,7 +81,7 @@ fun normalizeCatalogueRowLayoutKey(rowKey: String): String { return rowKey .trim() .lowercase() - .replace(ALPHANUMERIC_REGEX, "_") + .replace(CardLayoutModeRegexes.ALPHANUMERIC_REGEX, "_") .trim('_') .ifBlank { "default" } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSourceAttribution.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSourceAttribution.kt index 9b49e6910..eff417ec6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSourceAttribution.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSourceAttribution.kt @@ -3,15 +3,17 @@ package com.arflix.tv.ui.components import com.arflix.tv.data.model.StreamSource import java.util.Locale -private val attributionSeparators = Regex("[\\r\\n|\\u2022\\u00B7]+") -private val attributionUrl = Regex("(?i)(?:https?://|magnet:)\\S+") -private val attributionSize = Regex("(?i)\\b\\d+(?:\\.\\d+)?\\s*(?:TB|GB|MB|KB)\\b") -private val attributionTechnicalToken = Regex( - """(?i)(?= 2 }?.take(48) } @@ -72,4 +74,4 @@ private fun MutableList.addAttributionLabel(label: String?, addonLabel: private fun normalizedAttribution(value: String): String = value .lowercase(Locale.ROOT) - .replace(attributionNonAlphanumeric, "") + .replace(StreamSourceAttributionRegexes.attributionNonAlphanumeric, "") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt index 080eb791f..782860e2a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt @@ -13,8 +13,10 @@ internal const val AUTOPLAY_TOP_TIER_SETTLE_MS = 450L internal const val AUTOPLAY_SOURCE_RECHECK_MS = 120L private const val TOP_TIER_QUALITY_SCORE = 4 -private val fourKRegex = Regex("""\b4[kK]\b""") -private val sizeRegex = Regex("""(?i)(\d+(?:[\.,]\d+)?)\s*(TB|GB|MB|KB|B|GiB|MiB|KiB)?""") +private object AutoPlayRegexes { + val fourKRegex = Regex("""\b4[kK]\b""") + val sizeRegex = Regex("""(?i)(\d+(?:[\.,]\d+)?)\s*(TB|GB|MB|KB|B|GiB|MiB|KiB)?""") +} /** Score quality from all stream text because addons do not fill the quality field consistently. */ internal fun qualityScoreForAutoPlay(stream: StreamSource): Int { @@ -34,7 +36,7 @@ internal fun qualityScoreForAutoPlay(stream: StreamSource): Int { } } return when { - combined.contains("2160p", ignoreCase = true) || fourKRegex.containsMatchIn(combined) -> 4 + combined.contains("2160p", ignoreCase = true) || AutoPlayRegexes.fourKRegex.containsMatchIn(combined) -> 4 combined.contains("1080p", ignoreCase = true) -> 3 combined.contains("720p", ignoreCase = true) -> 2 combined.contains("480p", ignoreCase = true) -> 1 @@ -72,7 +74,7 @@ internal fun bestAutoPlayStream( internal fun autoPlaySizeBytes(stream: StreamSource): Long { val raw = stream.size.trim() if (raw.isBlank()) return 0L - val match = sizeRegex.find(raw) ?: return 0L + val match = AutoPlayRegexes.sizeRegex.find(raw) ?: return 0L val value = match.groupValues[1].replace(',', '.').toDoubleOrNull() ?: return 0L val unit = match.groupValues.getOrNull(2)?.uppercase(Locale.US).orEmpty() val multiplier = when (unit) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index d731cbea3..32b006e6e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -1144,8 +1144,12 @@ class DetailsViewModel @Inject constructor( // Force-refresh watched episodes from backend (not just in-memory cache) // to pick up episodes marked watched during playback. val watchedKeys = if (mediaType == MediaType.TV) { - runCatching { traktRepository.getWatchedEpisodesForShow(tmdbId) } - .getOrDefault(traktRepository.getWatchedEpisodesFromCache()) + try { + traktRepository.getWatchedEpisodesForShow(tmdbId) + } catch (e: Exception) { + if (e is CancellationException) throw e + traktRepository.getWatchedEpisodesFromCache() + } } else { emptySet() } @@ -1224,9 +1228,12 @@ class DetailsViewModel @Inject constructor( return try { val tvDetails = tmdbApi.getTvDetails(tmdbId, Constants.TMDB_API_KEY) for (seasonNum in 1..tvDetails.numberOfSeasons) { - val seasonDetails = runCatching { + val seasonDetails = try { tmdbApi.getTvSeason(tmdbId, seasonNum, Constants.TMDB_API_KEY) - }.getOrNull() ?: continue + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } ?: continue val firstUnwatched = seasonDetails.episodes.firstOrNull { episode -> val key = "show_tmdb:$tmdbId:$seasonNum:${episode.episodeNumber}" !watchedKeys.contains(key) @@ -1241,7 +1248,8 @@ class DetailsViewModel @Inject constructor( } // All episodes watched — offer restart PlayTarget(season = 1, episode = 1, label = context.getString(R.string.play_start_s1e1)) - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e null } } @@ -2194,6 +2202,7 @@ class DetailsViewModel @Inject constructor( else -> cloudResume } } catch (e: Exception) { + if (e is CancellationException) throw e null } } @@ -2203,10 +2212,13 @@ class DetailsViewModel @Inject constructor( val entry = watchHistoryRepository.getLatestProgress(mediaType, tmdbId) ?: return null if (mediaType == MediaType.TV && entry.season != null && entry.episode != null) { val watchedKey = "show_tmdb:$tmdbId:${entry.season}:${entry.episode}" - val isWatched = runCatching { + val isWatched = try { traktRepository.getWatchedEpisodesFromCache().contains(watchedKey) || traktRepository.getWatchedEpisodesForShow(tmdbId).contains(watchedKey) - }.getOrDefault(false) + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } if (isWatched) return null } buildResumeFromProgress( @@ -2218,7 +2230,8 @@ class DetailsViewModel @Inject constructor( positionSeconds = entry.position_seconds, durationSeconds = entry.duration_seconds ) - } catch (_: Exception) { + } catch (e: Exception) { + if (e is CancellationException) throw e null } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 285c647a6..6af8e2214 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -2238,7 +2238,7 @@ class HomeViewModel @Inject constructor( val cachedContinueWatching = preloadStartupContinueWatchingItems() val savedCatalogs = withContext(networkDispatcher) { - runCatching { + try { streamRepository.removeCustomAddonsByUrl( CollectionTemplateManifest.autoInstalledAddonUrls() + listOf(MediaRepository.STREAMING_COLLECTION_ADDON_URL) @@ -2258,11 +2258,16 @@ class HomeViewModel @Inject constructor( mediaRepository.getDefaultCatalogConfigs() ) catalogRepository.getCatalogs() - }.getOrElse { + } catch (e: Exception) { + if (e is CancellationException) throw e // If sync/defaults fail, fall back to whatever is already saved // (includes user's custom Trakt catalogs) instead of only preinstalled defaults. - runCatching { catalogRepository.getCatalogs() } - .getOrDefault(mediaRepository.getDefaultCatalogConfigs()) + try { + catalogRepository.getCatalogs() + } catch (e2: Exception) { + if (e2 is CancellationException) throw e2 + mediaRepository.getDefaultCatalogConfigs() + } } } savedCatalogById.clear() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt index 2596ddc09..0383e3ec0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/login/LoginViewModel.kt @@ -72,8 +72,18 @@ class LoginViewModel @Inject constructor( // down on a fresh login. This is why TV-side changes weren't visible // on the phone even after logout/login. if (result.isSuccess) { - runCatching { cloudSyncRepository.pullFromCloud(pushPendingLocalFirst = false) } - runCatching { streamRepository.syncAddonsFromCloud() } + try { + cloudSyncRepository.pullFromCloud(pushPendingLocalFirst = false) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e) + } + try { + streamRepository.syncAddonsFromCloud() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e) + } } _uiState.update { state -> @@ -142,8 +152,18 @@ class LoginViewModel @Inject constructor( val authResult = authRepository.handleGoogleSignInResult(result) if (authResult.isSuccess) { - runCatching { cloudSyncRepository.pullFromCloud(pushPendingLocalFirst = false) } - runCatching { streamRepository.syncAddonsFromCloud() } + try { + cloudSyncRepository.pullFromCloud(pushPendingLocalFirst = false) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e) + } + try { + streamRepository.syncAddonsFromCloud() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e) + } } _uiState.update { state -> diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index d5b726974..b7f5b6962 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -409,6 +409,7 @@ class TvViewModel @Inject constructor( startCompleteEpgBackfill() warmXtreamVodCache() }.onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error logIptvRefreshFailure( error = error, phase = "load_snapshot", @@ -416,9 +417,12 @@ class TvViewModel @Inject constructor( forceEpg = forceEpg, hasExistingChannels = hasExistingChannels ) - val fallback = runCatching { + val fallback = try { iptvRepository.getMemoryCachedSnapshot() ?: iptvRepository.getCachedSnapshotOrNull() - }.getOrNull() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } if (fallback != null && fallback.channels.isNotEmpty()) { val currentState = _uiState.value val mergedFallback = mergeIncomingSnapshotWithCurrentGuide(fallback, currentState) @@ -478,7 +482,7 @@ class TvViewModel @Inject constructor( private fun warmXtreamVodCache() { if (warmVodJob?.isActive == true) return warmVodJob = viewModelScope.launch(Dispatchers.IO) { - runCatching { iptvRepository.warmXtreamVodCachesIfPossible() } + try { iptvRepository.warmXtreamVodCachesIfPossible() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e } }.also { job -> job.invokeOnCompletion { warmVodJob = null } } @@ -728,7 +732,7 @@ class TvViewModel @Inject constructor( private fun isActiveLargeIptvList(): Boolean { val snapshotCount = _uiState.value.snapshot.channels.size if (isLargeIptvList(snapshotCount)) return true - return runCatching { iptvRepository.pagedChannelStoreCount() }.getOrDefault(0) > 10_000 + return try { iptvRepository.pagedChannelStoreCount() > 10_000 } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } } /** @@ -739,7 +743,7 @@ class TvViewModel @Inject constructor( private fun lookupChannelById(state: TvUiState, id: String): IptvChannel? { if (id.isBlank()) return null return if (isLargeIptvList(state.snapshot.channels.size) || - runCatching { iptvRepository.pagedChannelStoreCount() }.getOrDefault(0) > 10_000 + try { iptvRepository.pagedChannelStoreCount() > 10_000 } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } ) { iptvRepository.pagedChannelsByIds(listOf(id)).firstOrNull() } else { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt index ae8e75513..e494f5ac5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistViewModel.kt @@ -545,6 +545,8 @@ class WatchlistViewModel @Inject constructor( } } + + private fun updateAvailableSources( catalogs: List? = null, homeServerCandidates: List? = null @@ -934,13 +936,15 @@ class WatchlistViewModel @Inject constructor( if (initialLocalItems.isEmpty()) { withTimeoutOrNull(3_500) { - runCatching { cloudSyncRepository.pullFromCloud() } - .onFailure { error -> - AppLogger.recordException( - throwable = error, - context = watchlistDiagnosticContext("startup_cloud_pull") - ) - } + try { + cloudSyncRepository.pullFromCloud() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException( + throwable = e, + context = watchlistDiagnosticContext("startup_cloud_pull") + ) + } } val cloudItems = watchlistRepository.getLocalWatchlistItems().watchlistDisplayOrder().enrichWithPlaybackProgress() if (cloudItems.isNotEmpty()) { @@ -956,7 +960,7 @@ class WatchlistViewModel @Inject constructor( } } - val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + val remoteConnected = try { remoteSyncManager.isRemoteConnected() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } if (!remoteConnected) { val items = watchlistRepository.getLocalWatchlistItems().watchlistDisplayOrder().enrichWithPlaybackProgress() sourceItemsCache[WatchlistSourceItem.MyWatchlist.id] = items @@ -1069,7 +1073,7 @@ class WatchlistViewModel @Inject constructor( val hadItems = _uiState.value.allItems.isNotEmpty() _uiState.value = _uiState.value.copy(isLoading = !hadItems) try { - val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + val remoteConnected = try { remoteSyncManager.isRemoteConnected() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } val syncedFromTrakt = if (remoteConnected) { withTimeoutOrNull(15_000) { syncTraktWatchlistSuspend() } ?: false } else { @@ -1109,7 +1113,7 @@ class WatchlistViewModel @Inject constructor( loadActiveSourceItems(forceRefresh = true) } else { viewModelScope.launch { - val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + val remoteConnected = try { remoteSyncManager.isRemoteConnected() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } if (!remoteConnected || traktSyncInFlight) return@launch val syncedFromTrakt = withTimeoutOrNull(10_000) { syncTraktWatchlistSuspend() } ?: false if (!syncedFromTrakt && _uiState.value.isLoading) { @@ -1143,7 +1147,7 @@ class WatchlistViewModel @Inject constructor( if (_uiState.value.selectedSourceId != WatchlistSourceItem.MyWatchlist.id) return viewModelScope.launch { try { - val remoteConnected = runCatching { remoteSyncManager.isRemoteConnected() }.getOrDefault(false) + val remoteConnected = try { remoteSyncManager.isRemoteConnected() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; false } val isAnime = item.mediaType == TV && item.originalLanguage.equals("ja", ignoreCase = true) && item.genreIds.contains(16) @@ -1164,13 +1168,15 @@ class WatchlistViewModel @Inject constructor( toastMessage = context.getString(R.string.watchlist_toast_removed), toastType = ToastType.SUCCESS ) - runCatching { cloudSyncRepository.pushToCloud() } - .onFailure { error -> - AppLogger.recordException( - throwable = error, - context = watchlistDiagnosticContext("remove_cloud_push") - ) - } + try { + cloudSyncRepository.pushToCloud() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.recordException( + throwable = e, + context = watchlistDiagnosticContext("remove_cloud_push") + ) + } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e