From 83bd54a6f29ee9921ecf2db76b2bf85d7b9dad81 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:32:36 +0000 Subject: [PATCH 1/9] chore(refactor): harden exception safety and error resilience in repository layers --- .../tv/data/repository/SportsRepository.kt | 8 ++++--- .../tv/data/repository/TraktRepository.kt | 7 ++++-- .../data/repository/TvDeviceAuthRepository.kt | 24 ++++++++++++++----- .../data/repository/WatchHistoryRepository.kt | 19 ++++++++++++--- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index ee3d28dc5..82222a959 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -340,16 +340,18 @@ class SportsRepository @Inject constructor( ): List { val baseUrl = addonBaseUrl(addon) ?: return emptyList() val url = "$baseUrl/catalog/${encodePathSegment(catalog.type)}/${encodePathSegment(catalog.id)}.json" - return runCatching { + return try { val response = streamApi.getAddonCatalog(url) response.metas ?: response.items ?: emptyList() - }.onFailure { error -> + } catch (error: Exception) { + if (error is kotlinx.coroutines.CancellationException) throw error AppLogger.breadcrumb( tag = "Sports", message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${error::class.java.simpleName}", severity = "warning" ) - }.getOrDefault(emptyList()) + emptyList() + } } private fun placeholderItem( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index c7da631b1..670762176 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -3194,7 +3194,7 @@ class TraktRepository @Inject constructor( if (normalizedTitle.isBlank()) return null if (year == null && !allowTitleOnly) return null - return runCatching { + return try { val results = when (mediaType) { MediaType.MOVIE -> tmdbApi.searchMovies( apiKey = Constants.TMDB_API_KEY, @@ -3236,7 +3236,10 @@ class TraktRepository @Inject constructor( .firstOrNull() ?.id ?.takeIf { it > 0 } - }.getOrNull() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } } private fun isWatchlistMatch( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt index d529768b1..7222e6cfc 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt @@ -52,7 +52,7 @@ class TvDeviceAuthRepository @Inject constructor( suspend fun startSession(): Result { return withContext(Dispatchers.IO) { - runCatching { + try { val request = Request.Builder() .url(Constants.TV_AUTH_START_URL) .header("apikey", Constants.APP_ANON_KEY) @@ -60,7 +60,7 @@ class TvDeviceAuthRepository @Inject constructor( .post("{}".toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(request).execute().use { response -> + val session = okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed_start))) @@ -78,13 +78,17 @@ class TvDeviceAuthRepository @Inject constructor( intervalSeconds = json.optInt("interval", 3) ) } + Result.success(session) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) } } } suspend fun pollStatus(deviceCode: String): Result { return withContext(Dispatchers.IO) { - runCatching { + try { val payload = JSONObject().put("device_code", deviceCode).toString() val statusRequest = Request.Builder() .url(Constants.TV_AUTH_STATUS_URL) @@ -93,7 +97,7 @@ class TvDeviceAuthRepository @Inject constructor( .post(payload.toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(statusRequest).execute().use { response -> + val status = okHttpClient.newCall(statusRequest).execute().use { response -> val body = response.body?.string().orEmpty() if (response.code == 404) { // Backward compatibility for older deployments still using /tv-auth-poll @@ -116,6 +120,10 @@ class TvDeviceAuthRepository @Inject constructor( } parseStatus(body) } + Result.success(status) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) } } } @@ -133,7 +141,7 @@ class TvDeviceAuthRepository @Inject constructor( return Result.failure(IllegalArgumentException(message)) } return withContext(Dispatchers.IO) { - runCatching { + try { val payload = JSONObject() .put("code", userCode) .put("email", normalizedEmail) @@ -148,13 +156,17 @@ class TvDeviceAuthRepository @Inject constructor( .post(payload.toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(request).execute().use { response -> + val result = okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed))) } TvDeviceAuthCompleteResult(ok = true) } + Result.success(result) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) } } } 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 0b2bf542d..29790a489 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 @@ -192,7 +192,12 @@ class WatchHistoryRepository @Inject constructor( } } cachedContinueWatchingByProfile[profileId] = cachedContinueWatching - runCatching { realtimeSyncManagerProvider.get().markLocalWatchHistoryWrite() } + try { + 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) + } return } @@ -205,12 +210,15 @@ class WatchHistoryRepository @Inject constructor( } saved = true } catch (e: HttpException) { - runCatching { + try { val fallback = entry.copy(stream_key = null, stream_addon_id = null, stream_title = null) executeSupabaseCall("save watch progress fallback") { auth -> 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) { AppLogger.e("WatchHistoryRepository", "Error in watch history operation", e) @@ -239,7 +247,12 @@ class WatchHistoryRepository @Inject constructor( } } cachedContinueWatchingByProfile[profileId] = cachedContinueWatching - runCatching { realtimeSyncManagerProvider.get().markLocalWatchHistoryWrite() } + try { + 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) + } } } From 9b119bde6eb5b87f53e34c3e712464bd42e13a50 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:42:18 +0000 Subject: [PATCH 2/9] chore(refactor): optimize Iptv models and harden error handling --- .../com/arflix/tv/data/model/IptvModels.kt | 6 ++++++ .../tv/data/repository/IptvChannelStore.kt | 18 ++++++++++-------- .../data/repository/IptvPlaybackUrlResolver.kt | 10 +++++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt index e7a0b3d88..1f96080b1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt @@ -1,5 +1,6 @@ package com.arflix.tv.data.model +import androidx.compose.runtime.Immutable import java.time.Instant /** @@ -12,6 +13,7 @@ import java.time.Instant * @property licenseData Optional PSSH override (base64). Most MPD manifests declare * PSSH inline or in the init segment; ExoPlayer handles both automatically. */ +@Immutable data class DrmInfo( val scheme: String, val licenseUrl: String? = null, @@ -21,6 +23,7 @@ data class DrmInfo( /** * IPTV channel parsed from an M3U playlist. */ +@Immutable data class IptvChannel( val id: String, val name: String, @@ -46,6 +49,7 @@ data class IptvChannel( /** * Compact now/next program slice for a channel. */ +@Immutable data class IptvNowNext( val now: IptvProgram? = null, val next: IptvProgram? = null, @@ -57,6 +61,7 @@ data class IptvNowNext( /** * EPG program row. */ +@Immutable data class IptvProgram( val title: String, val description: String? = null, @@ -71,6 +76,7 @@ data class IptvProgram( /** * Loaded IPTV snapshot used by UI. */ +@Immutable data class IptvSnapshot( val channels: List = emptyList(), val grouped: Map> = emptyMap(), diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt index 01cf01700..398fa60b7 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt @@ -344,15 +344,17 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( fun deleteSource(sourceKey: String) { if (sourceKey.isBlank()) return - writableDatabase.runCatching { - beginTransaction() + try { + writableDatabase.beginTransaction() try { - delete("channels", "source_key = ?", arrayOf(sourceKey)) - delete("channel_sources", "source_key = ?", arrayOf(sourceKey)) - setTransactionSuccessful() + writableDatabase.delete("channels", "source_key = ?", arrayOf(sourceKey)) + writableDatabase.delete("channel_sources", "source_key = ?", arrayOf(sourceKey)) + writableDatabase.setTransactionSuccessful() } finally { - endTransaction() + writableDatabase.endTransaction() } + } catch (e: Exception) { + // Ignore DB errors on delete } } @@ -361,9 +363,9 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( val drmJson = if (cursor.isNull(c.drm)) null else cursor.getString(c.drm) @Suppress("UNCHECKED_CAST") val headers = headersJson?.let { - runCatching { gson.fromJson(it, Map::class.java) as? Map }.getOrNull() + try { gson.fromJson(it, Map::class.java) as? Map } catch (e: Exception) { null } }.orEmpty() - val drm = drmJson?.let { runCatching { gson.fromJson(it, DrmInfo::class.java) }.getOrNull() } + val drm = drmJson?.let { try { gson.fromJson(it, DrmInfo::class.java) } catch (e: Exception) { null } } val name = cursor.getString(c.name).orEmpty() return IptvChannel( id = cursor.getString(c.id).orEmpty(), 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 86a817ee4..51679d04b 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 @@ -74,7 +74,7 @@ internal class IptvPlaybackUrlResolver( headers: Map, useHead: Boolean, ): ProbeResult? { - return runCatching { + return try { val request = Request.Builder() .url(url) .apply { @@ -117,7 +117,11 @@ internal class IptvPlaybackUrlResolver( contentType.isDirectMediaContentType(), ) } - }.getOrNull() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + null + } } } @@ -130,7 +134,7 @@ internal fun shouldResolveIptvPlaybackRedirect(url: String): Boolean { } if (looksLikeHlsPlaybackUrl(trimmed)) return false - val uri = runCatching { URI(trimmed) }.getOrNull() ?: return false + val uri = try { URI(trimmed) } catch (e: Exception) { null } ?: return false val path = uri.path.orEmpty().trimEnd('/').lowercase(Locale.US) val lastSegment = path.substringAfterLast('/') if (lastSegment.isBlank() || lastSegment.contains('.')) return false From 931be50b3ad789a1b94eab6b8f09aedaf6cff40b Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:06:38 +0000 Subject: [PATCH 3/9] refactor(core): harden exception safety and json validation in repository layers --- .../arflix/tv/data/repository/AuthRepository.kt | 16 ++++++++-------- .../tv/data/repository/CloudSyncRepository.kt | 6 +++--- .../arflix/tv/data/repository/IptvRepository.kt | 3 +-- .../arflix/tv/data/repository/MediaRepository.kt | 2 +- 4 files changed, 13 insertions(+), 14 deletions(-) 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 e91f02732..09201e64d 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 @@ -126,7 +126,7 @@ private data class AccountSyncPayloadCandidate( private class AccountSyncPayloadRejectedException(message: String) : Exception(message) private fun parseJsonObject(payload: String): com.google.gson.JsonObject? { - return runCatching { JsonParser().parse(payload).asJsonObject }.getOrNull() + return try { JsonParser().parse(payload).asJsonObject } catch (e: com.google.gson.JsonSyntaxException) { null } catch (e: IllegalStateException) { null } } internal fun accountSyncPayloadProfileCount(payload: String): Int? { @@ -225,7 +225,7 @@ private fun accountSyncPayloadsMatch(expected: String, actual: String?): Boolean private fun safePostgrestError(body: String): String { if (body.isBlank()) return "empty response" - val parsed = runCatching { JSONObject(body) }.getOrNull() + val parsed = try { JSONObject(body) } catch (e: org.json.JSONException) { null } return parsed?.optString("message")?.takeIf { it.isNotBlank() } ?: parsed?.optString("error")?.takeIf { it.isNotBlank() } ?: body.take(180) @@ -679,7 +679,7 @@ class AuthRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() - val json = runCatching { JSONObject(body) }.getOrNull() + val json = try { JSONObject(body) } catch (e: org.json.JSONException) { null } if (!response.isSuccessful) { val message = cloudAuthErrorMessage(json, defaultError) throw IllegalStateException(message) @@ -1708,7 +1708,7 @@ class AuthRepository @Inject constructor( private suspend fun saveAccountSyncPayloadToNetlify(payload: String): Result { return try { - val payloadValue = runCatching { JSONObject(payload) }.getOrNull() ?: payload + val payloadValue = try { JSONObject(payload) } catch (e: org.json.JSONException) { null } ?: payload val body = JSONObject() .put("payload", payloadValue) .toString() @@ -1716,7 +1716,7 @@ class AuthRepository @Inject constructor( url = Constants.NETLIFY_ACCOUNT_SYNC_PUSH_URL, body = body ) - val responseJson = runCatching { JSONObject(responseBody) }.getOrNull() + val responseJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null } if (responseJson?.optBoolean("accepted", true) == false) { val reason = responseJson.optString("reason", "existing_snapshot_is_richer") throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason") @@ -1752,7 +1752,7 @@ class AuthRepository @Inject constructor( "Cloud sync upload failed (${response.code}): ${safePostgrestError(responseBody)}" ) } - val rpcJson = runCatching { JSONObject(responseBody) }.getOrNull() + val rpcJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null } if (rpcJson?.optBoolean("accepted", true) == false) { val reason = rpcJson.optString("reason", "existing_snapshot_is_richer") throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason") @@ -2110,7 +2110,7 @@ class AuthRepository @Inject constructor( private fun parseInstantMillis(value: String?): Long { if (value.isNullOrBlank()) return 0L - return runCatching { Instant.parse(value).toEpochMilliseconds() }.getOrDefault(0L) + return try { Instant.parse(value).toEpochMilliseconds() } catch (e: Exception) { 0L } } private fun encodeProfileAccountSyncPayload(existingAddons: String?, payload: String): String { @@ -2139,7 +2139,7 @@ class AuthRepository @Inject constructor( val root = if (existingPayload.isBlank()) { JSONObject() } else { - runCatching { JSONObject(existingPayload) }.getOrElse { JSONObject() } + try { JSONObject(existingPayload) } catch (e: org.json.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 6eb1fae28..9bf6bef32 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 @@ -551,11 +551,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 = runCatching { JSONObject(baseStr) }.getOrNull() ?: return SettingsMergeResult(baseStr, emptySet()) - val other = runCatching { JSONObject(otherStr) }.getOrNull() ?: return SettingsMergeResult(baseStr, emptySet()) + 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 baseTs = base.optJSONObject("fieldUpdatedAt") ?: JSONObject() val otherTs = other.optJSONObject("fieldUpdatedAt") ?: JSONObject() - val mergedTs = runCatching { JSONObject(baseTs.toString()) }.getOrDefault(JSONObject()) + val mergedTs = try { JSONObject(baseTs.toString()) } catch (e: org.json.JSONException) { JSONObject() } val otherWon = HashSet() val allKeys = LinkedHashSet().apply { addAll(mergeKeysOf(base)); addAll(mergeKeysOf(other)) } for (key in allKeys) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 08d0becd5..9f87604a9 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -1242,8 +1242,7 @@ class IptvRepository @Inject constructor( if (pattern in setOf("Y", "m", "d", "H", "M", "S")) { return@replace match.value } - try { Result.success(dateTime.format(IptvRepoDateRegexes.formatterFor(pattern))) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e - Result.failure(e) } + runCatching { dateTime.format(IptvRepoDateRegexes.formatterFor(pattern)) } .getOrDefault(match.value) } } 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 36d8bbc81..2778be693 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 @@ -2041,7 +2041,7 @@ class MediaRepository @Inject constructor( val body = withContext(Dispatchers.IO) { fetchUrl("https://mdblist.com/lists/$slug/json") } ?: return emptyList() - val array = runCatching { JSONArray(body) }.getOrNull() ?: return emptyList() + val array = try { org.json.JSONArray(body) } catch (e: org.json.JSONException) { null } ?: return emptyList() val refs = mutableListOf>() for (i in 0 until array.length()) { val obj = array.optJSONObject(i) ?: continue From 779143552633eac1ad2b570d736af11a7419b10a Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:00:25 +0000 Subject: [PATCH 4/9] chore(refactor): optimize regex allocations and harden exception handling --- .../data/repository/TvDeviceAuthRepository.kt | 31 +++++++++++++------ .../arflix/tv/ui/components/StreamSelector.kt | 10 ++++-- .../tv/ui/screens/player/PlayerViewModel.kt | 8 ++++- .../ui/screens/player/SubtitleSyncMatcher.kt | 7 ++++- .../ui/screens/settings/SettingsViewModel.kt | 4 +-- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt index d529768b1..93886fc75 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt @@ -52,7 +52,7 @@ class TvDeviceAuthRepository @Inject constructor( suspend fun startSession(): Result { return withContext(Dispatchers.IO) { - runCatching { + try { val request = Request.Builder() .url(Constants.TV_AUTH_START_URL) .header("apikey", Constants.APP_ANON_KEY) @@ -70,21 +70,24 @@ class TvDeviceAuthRepository @Inject constructor( val verificationUrl = json.optString("verification_url") .ifBlank { json.optString("verification_uri") } .ifBlank { "https://auth.arvio.tv/?code=${java.net.URLEncoder.encode(userCode, "UTF-8")}" } - TvDeviceAuthSession( + Result.success(TvDeviceAuthSession( userCode = userCode, deviceCode = json.getString("device_code"), verificationUrl = verificationUrl, expiresInSeconds = json.optInt("expires_in", 600), intervalSeconds = json.optInt("interval", 3) - ) + )) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) } } } suspend fun pollStatus(deviceCode: String): Result { return withContext(Dispatchers.IO) { - runCatching { + try { val payload = JSONObject().put("device_code", deviceCode).toString() val statusRequest = Request.Builder() .url(Constants.TV_AUTH_STATUS_URL) @@ -108,14 +111,17 @@ class TvDeviceAuthRepository @Inject constructor( if (!fallback.isSuccessful) { throw IllegalStateException(parseError(fallbackBody, context.getString(R.string.tv_link_failed_poll))) } - return@use parseStatus(fallbackBody) + return@use Result.success(parseStatus(fallbackBody)) } } if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed_poll))) } - parseStatus(body) + Result.success(parseStatus(body)) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) } } } @@ -133,7 +139,7 @@ class TvDeviceAuthRepository @Inject constructor( return Result.failure(IllegalArgumentException(message)) } return withContext(Dispatchers.IO) { - runCatching { + try { val payload = JSONObject() .put("code", userCode) .put("email", normalizedEmail) @@ -153,21 +159,26 @@ class TvDeviceAuthRepository @Inject constructor( if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed))) } - TvDeviceAuthCompleteResult(ok = true) + Result.success(TvDeviceAuthCompleteResult(ok = true)) } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) } } } private fun parseError(body: String, fallback: String): String { - return runCatching { + return try { val json = JSONObject(body) json.optString("error").ifBlank { json.optString("message").ifBlank { json.optString("error_description").ifBlank { fallback } } } - }.getOrDefault(fallback) + } catch (e: org.json.JSONException) { + fallback + } } private fun parseStatus(body: String): TvDeviceAuthStatus { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index b2616e8f0..dbd05023f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -1362,7 +1362,7 @@ private fun presentSource(stream: StreamSource): SourcePresentation { upstreamLabel = stream.description.orEmpty().lines() .firstOrNull { it.trimStart().startsWith("๐Ÿ”Œ") } // Keep "Torrentio | ThePirateBay", drop the emoji decorations. - ?.replace(Regex("""[^\p{L}\p{N} .+|\-]"""), "") + ?.replace(StreamSelectorRegexes.CLEAN_TITLE_REGEX, "") ?.trim() ?.takeIf { it.isNotBlank() }, ) @@ -1472,7 +1472,7 @@ private fun rowSubtitle(presentation: SourcePresentation): String { presentation.editionLabel?.let(::add) presentation.bitrateLabel?.let(::add) } - .distinctBy { it.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") } + .distinctBy { it.lowercase().replace(StreamSelectorRegexes.DISTINCT_TITLE_REGEX, "") } .joinToString(" ยท ") } @@ -2659,3 +2659,9 @@ private fun qualityScore(quality: String): Int { else -> 0 } } + + +private object StreamSelectorRegexes { + val CLEAN_TITLE_REGEX = Regex("""[^\p{L}\p{N} .+|\-]""") + val DISTINCT_TITLE_REGEX = Regex("[^\\p{L}\\p{N}]+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 330da96dd..6281df520 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -3042,7 +3042,7 @@ class PlayerViewModel @Inject constructor( /** Whitespace/tag-insensitive form for comparing renderer cue text against parsed file text. */ private fun normalizeCueTextForCompare(text: String): String = - text.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim() + text.replace(PlayerVMRegexes.HTML_TAGS, " ").replace(PlayerVMRegexes.MULTIPLE_SPACES, " ").trim() /** * A scored candidate. [offsetMs] is 0 for a normal (as-authored) match, or the uniform delay @@ -4531,3 +4531,9 @@ class PlayerViewModel @Inject constructor( ) } } + + +private object PlayerVMRegexes { + val HTML_TAGS = Regex("<[^>]*>") + val MULTIPLE_SPACES = Regex("\\s+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt index c6492cfa0..15b1044da 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt @@ -272,7 +272,7 @@ object SubtitleSyncMatcher { else -> sb.append(' ') } } - return sb.toString().replace(Regex("\\s+"), " ").trim() + return sb.toString().replace(SubtitleSyncRegexes.MULTIPLE_SPACES, " ").trim() } fun cueTextAt(cues: List, timeMs: Long): String? = @@ -331,3 +331,8 @@ object SubtitleSyncMatcher { }.getOrNull() } } + + +private object SubtitleSyncRegexes { + val MULTIPLE_SPACES = Regex("\\s+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 69386dbec..b2a27495d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1509,7 +1509,7 @@ class SettingsViewModel @Inject constructor( fun addQualityFilter(deviceName: String, regexPattern: String) { val trimmedRegex = regexPattern.trim() if (trimmedRegex.isBlank()) return - if (runCatching { Regex(trimmedRegex) }.isFailure) return + try { Regex(trimmedRegex) } catch (e: Exception) { return } viewModelScope.launch { val next = _uiState.value.qualityFilters + QualityFilterConfig( @@ -1525,7 +1525,7 @@ class SettingsViewModel @Inject constructor( fun updateQualityFilter(filterId: String, deviceName: String, regexPattern: String) { val trimmedRegex = regexPattern.trim() if (trimmedRegex.isBlank()) return - if (runCatching { Regex(trimmedRegex) }.isFailure) return + try { Regex(trimmedRegex) } catch (e: Exception) { return } viewModelScope.launch { val next = _uiState.value.qualityFilters.map { filter -> From 9300896c8980b09dff07758445c58e8014f42919 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:44:46 +0000 Subject: [PATCH 5/9] refactor(core): harden exception safety and edge-case validation in repository layers --- .../repository/ProfileAvatarImageManager.kt | 23 +++++-- .../tv/data/repository/SportsRepository.kt | 24 +++++-- .../tv/data/repository/TraktRepository.kt | 63 +++++++++++++++---- 3 files changed, 91 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt index 57bb9b45a..715cf2bba 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt @@ -71,11 +71,19 @@ class ProfileAvatarImageManager @Inject constructor( ?: loadInlineAvatarFromCloud(profile.id) if (!resolvedInlineBase64.isNullOrBlank()) { - runCatching { + try { val bytes = Base64.decode(resolvedInlineBase64, Base64.NO_WRAP) file.writeBytes(bytes) ProfileAvatarFiles.cleanupProfile(context, profile.id, keepVersion = profile.avatarImageVersion) - }.onSuccess { return@withContext } + return@withContext + } catch (e: IllegalArgumentException) { + com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Base64 decode error: ${e.message}") + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("ProfileAvatar", "IO error writing avatar: ${e.message}") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Unexpected error restoring avatar: ${e.message}") + } } val storagePath = profile.avatarImageStoragePath?.trim().orEmpty() @@ -203,12 +211,19 @@ class ProfileAvatarImageManager @Inject constructor( return authRepository.loadAccountSyncPayload().getOrNull() ?.takeIf { it.isNotBlank() } ?.let { payload -> - runCatching { + try { JSONObject(payload) .optJSONObject("profileAvatarImagesById") ?.optString(profileId) ?.takeIf { it.isNotBlank() } - }.getOrNull() + } catch (e: org.json.JSONException) { + com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Error parsing inline avatar JSON: ${e.message}") + null + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Unexpected error parsing inline avatar: ${e.message}") + null + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index ee3d28dc5..c739d3450 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -340,16 +340,32 @@ class SportsRepository @Inject constructor( ): List { val baseUrl = addonBaseUrl(addon) ?: return emptyList() val url = "$baseUrl/catalog/${encodePathSegment(catalog.type)}/${encodePathSegment(catalog.id)}.json" - return runCatching { + return try { val response = streamApi.getAddonCatalog(url) response.metas ?: response.items ?: emptyList() - }.onFailure { error -> + } catch (e: retrofit2.HttpException) { AppLogger.breadcrumb( tag = "Sports", - message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${error::class.java.simpleName}", + message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${e::class.java.simpleName}", severity = "warning" ) - }.getOrDefault(emptyList()) + emptyList() + } catch (e: java.io.IOException) { + AppLogger.breadcrumb( + tag = "Sports", + message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${e::class.java.simpleName}", + severity = "warning" + ) + emptyList() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.breadcrumb( + tag = "Sports", + message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${e::class.java.simpleName}", + severity = "warning" + ) + emptyList() + } } private fun placeholderItem( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 6208ce551..42059f291 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -2920,9 +2920,16 @@ class TraktRepository @Inject constructor( } val body = response.body?.string().orEmpty() val listType = TypeToken.getParameterized(List::class.java, TraktWatchlistItem::class.java).type - val items: List = runCatching { - gson.fromJson>(body, listType) - }.getOrNull().orEmpty() + val items: List = try { + gson.fromJson(body, listType) + } catch (e: com.google.gson.JsonSyntaxException) { + com.arflix.tv.util.AppLogger.e("Trakt", "Failed to parse watchlist items: ${e.message}") + emptyList() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("Trakt", "Unexpected error parsing watchlist items: ${e.message}") + emptyList() + } WatchlistPageResult( items = items, totalPages = response.header("X-Pagination-Page-Count")?.toIntOrNull(), @@ -3106,10 +3113,18 @@ class TraktRepository @Inject constructor( val imdbId = movie.ids.imdb?.trim()?.takeIf { it.isNotEmpty() } val ids = buildList { imdbId?.let { id -> - runCatching { + try { tmdbApi.findByExternalId(id, Constants.TMDB_API_KEY).movieResults .mapNotNull { it.id.takeIf { tmdbId -> tmdbId > 0 } } - }.getOrNull()?.let { addAll(it) } + .let { addAll(it) } + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("Trakt", "HTTP error finding movie by ID: ${e.message}") + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("Trakt", "Network error finding movie by ID: ${e.message}") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("Trakt", "Unexpected error finding movie by ID: ${e.message}") + } } movie.ids.tmdb?.takeIf { it > 0 }?.let { add(it) } }.distinct() @@ -3153,19 +3168,35 @@ class TraktRepository @Inject constructor( val imdbId = show.ids.imdb?.trim()?.takeIf { it.isNotEmpty() } val ids = buildList { imdbId?.let { id -> - runCatching { + try { tmdbApi.findByExternalId(id, Constants.TMDB_API_KEY).tvResults .mapNotNull { it.id.takeIf { tmdbId -> tmdbId > 0 } } - }.getOrNull()?.let { addAll(it) } + .let { addAll(it) } + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("Trakt", "HTTP error finding show by ID: ${e.message}") + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("Trakt", "Network error finding show by ID: ${e.message}") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("Trakt", "Unexpected error finding show by ID: ${e.message}") + } } show.ids.tvdb?.takeIf { it > 0 }?.let { tvdbId -> - runCatching { + try { tmdbApi.findByExternalId( tvdbId.toString(), Constants.TMDB_API_KEY, externalSource = "tvdb_id" ).tvResults.mapNotNull { it.id.takeIf { tmdbId -> tmdbId > 0 } } - }.getOrNull()?.let { addAll(it) } + .let { addAll(it) } + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("Trakt", "HTTP error finding show by TVDB ID: ${e.message}") + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("Trakt", "Network error finding show by TVDB ID: ${e.message}") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("Trakt", "Unexpected error finding show by TVDB ID: ${e.message}") + } } show.ids.tmdb?.takeIf { it > 0 }?.let { add(it) } }.distinct() @@ -3220,7 +3251,7 @@ class TraktRepository @Inject constructor( if (normalizedTitle.isBlank()) return null if (year == null && !allowTitleOnly) return null - return runCatching { + return try { val results = when (mediaType) { MediaType.MOVIE -> tmdbApi.searchMovies( apiKey = Constants.TMDB_API_KEY, @@ -3262,7 +3293,17 @@ class TraktRepository @Inject constructor( .firstOrNull() ?.id ?.takeIf { it > 0 } - }.getOrNull() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("Trakt", "HTTP error fuzzy matching TMDB ID: ${e.message}") + null + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("Trakt", "Network error fuzzy matching TMDB ID: ${e.message}") + null + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.e("Trakt", "Unexpected error fuzzy matching TMDB ID: ${e.message}") + null + } } private fun isWatchlistMatch( From b90c9b911914d51ebc08027a5ab5dccb30cdd027 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:55:27 +0000 Subject: [PATCH 6/9] refactor(core): harden exception safety and optimize regex allocations in repository layers --- .../data/repository/HomeServerRepository.kt | 2 +- .../repository/ProfileAvatarImageManager.kt | 34 +++++++++++--- .../tv/data/repository/SportsRepository.kt | 11 +++-- .../tv/data/repository/TraktRepository.kt | 2 +- .../data/repository/TvDeviceAuthRepository.kt | 47 +++++++++++++++---- 5 files changed, 75 insertions(+), 21 deletions(-) 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 45098c897..ec654b640 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 @@ -2529,7 +2529,7 @@ private object HomeServerXmlRegexCache { } } } -private object HomeServerRegexes { +internal object HomeServerRegexes { val DIACRITICS_REGEX = Regex("\\p{Mn}+") val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") val ARTICLES_REGEX = Regex("\\b(the|a|an)\\b") diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt index 57bb9b45a..787934f60 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt @@ -71,11 +71,14 @@ class ProfileAvatarImageManager @Inject constructor( ?: loadInlineAvatarFromCloud(profile.id) if (!resolvedInlineBase64.isNullOrBlank()) { - runCatching { + try { val bytes = Base64.decode(resolvedInlineBase64, Base64.NO_WRAP) file.writeBytes(bytes) ProfileAvatarFiles.cleanupProfile(context, profile.id, keepVersion = profile.avatarImageVersion) - }.onSuccess { return@withContext } + return@withContext + } catch (e: Exception) { + // Ignore decode or write errors and fallback + } } val storagePath = profile.avatarImageStoragePath?.trim().orEmpty() @@ -152,7 +155,7 @@ class ProfileAvatarImageManager @Inject constructor( private suspend fun uploadAvatar(profileId: String, version: Long, file: File): Result = withContext(Dispatchers.IO) { - runCatching { + try { if (Constants.USE_NETLIFY_CLOUD_SYNC) { error("Remote avatar storage is handled by account sync") } @@ -173,13 +176,19 @@ class ProfileAvatarImageManager @Inject constructor( httpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) error(context.getString(R.string.avatar_upload_failed, response.code)) } - path + Result.success(path) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: java.io.IOException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) } } private suspend fun downloadAvatar(storagePath: String, destination: File): Result = withContext(Dispatchers.IO) { - runCatching { + try { if (Constants.USE_NETLIFY_CLOUD_SYNC) { error("Remote avatar storage is handled by account sync") } @@ -196,6 +205,13 @@ class ProfileAvatarImageManager @Inject constructor( val bytes = response.body?.bytes() ?: error(context.getString(R.string.avatar_response_empty)) destination.writeBytes(bytes) } + Result.success(Unit) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: java.io.IOException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) } } @@ -203,12 +219,16 @@ class ProfileAvatarImageManager @Inject constructor( return authRepository.loadAccountSyncPayload().getOrNull() ?.takeIf { it.isNotBlank() } ?.let { payload -> - runCatching { + try { JSONObject(payload) .optJSONObject("profileAvatarImagesById") ?.optString(profileId) ?.takeIf { it.isNotBlank() } - }.getOrNull() + } catch (e: org.json.JSONException) { + null + } catch (e: Exception) { + null + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index ee3d28dc5..99e781574 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -340,16 +340,19 @@ class SportsRepository @Inject constructor( ): List { val baseUrl = addonBaseUrl(addon) ?: return emptyList() val url = "$baseUrl/catalog/${encodePathSegment(catalog.type)}/${encodePathSegment(catalog.id)}.json" - return runCatching { + return try { val response = streamApi.getAddonCatalog(url) response.metas ?: response.items ?: emptyList() - }.onFailure { error -> + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { AppLogger.breadcrumb( tag = "Sports", - message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${error::class.java.simpleName}", + message = "sports_catalog_failed addon=${addon.id} catalog=${catalog.id} error=${e::class.java.simpleName}", severity = "warning" ) - }.getOrDefault(emptyList()) + emptyList() + } } private fun placeholderItem( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 6208ce551..4546a4ae9 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -4455,7 +4455,7 @@ private fun buildEpisodeKey( } -private object TraktRepoRegexes { +internal object TraktRepoRegexes { val DIACRITICS_REGEX = Regex("\\p{Mn}+") val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") val HOURS_REGEX = Regex("""(\d+)\s*h""") diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt index d529768b1..a152e6095 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt @@ -52,7 +52,7 @@ class TvDeviceAuthRepository @Inject constructor( suspend fun startSession(): Result { return withContext(Dispatchers.IO) { - runCatching { + try { val request = Request.Builder() .url(Constants.TV_AUTH_START_URL) .header("apikey", Constants.APP_ANON_KEY) @@ -60,7 +60,7 @@ class TvDeviceAuthRepository @Inject constructor( .post("{}".toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(request).execute().use { response -> + val session = okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed_start))) @@ -78,13 +78,22 @@ class TvDeviceAuthRepository @Inject constructor( intervalSeconds = json.optInt("interval", 3) ) } + Result.success(session) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: java.io.IOException) { + Result.failure(e) + } catch (e: org.json.JSONException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) } } } suspend fun pollStatus(deviceCode: String): Result { return withContext(Dispatchers.IO) { - runCatching { + try { val payload = JSONObject().put("device_code", deviceCode).toString() val statusRequest = Request.Builder() .url(Constants.TV_AUTH_STATUS_URL) @@ -93,7 +102,7 @@ class TvDeviceAuthRepository @Inject constructor( .post(payload.toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(statusRequest).execute().use { response -> + val status = okHttpClient.newCall(statusRequest).execute().use { response -> val body = response.body?.string().orEmpty() if (response.code == 404) { // Backward compatibility for older deployments still using /tv-auth-poll @@ -116,6 +125,15 @@ class TvDeviceAuthRepository @Inject constructor( } parseStatus(body) } + Result.success(status) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: java.io.IOException) { + Result.failure(e) + } catch (e: org.json.JSONException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) } } } @@ -133,7 +151,7 @@ class TvDeviceAuthRepository @Inject constructor( return Result.failure(IllegalArgumentException(message)) } return withContext(Dispatchers.IO) { - runCatching { + try { val payload = JSONObject() .put("code", userCode) .put("email", normalizedEmail) @@ -148,26 +166,39 @@ class TvDeviceAuthRepository @Inject constructor( .post(payload.toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(request).execute().use { response -> + val completeResult = okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed))) } TvDeviceAuthCompleteResult(ok = true) } + Result.success(completeResult) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: java.io.IOException) { + Result.failure(e) + } catch (e: org.json.JSONException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) } } } private fun parseError(body: String, fallback: String): String { - return runCatching { + return try { val json = JSONObject(body) json.optString("error").ifBlank { json.optString("message").ifBlank { json.optString("error_description").ifBlank { fallback } } } - }.getOrDefault(fallback) + } catch (e: org.json.JSONException) { + fallback + } catch (e: Exception) { + fallback + } } private fun parseStatus(body: String): TvDeviceAuthStatus { From 873566a5480f8e09bd59fdc3a305c23f7fd34951 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:59:40 +0000 Subject: [PATCH 7/9] chore(refactor): optimize recomposition and stream parsing layers --- .../com/arflix/tv/data/repository/HomeServerRepository.kt | 8 ++++++-- .../com/arflix/tv/ui/screens/player/PlayerViewModel.kt | 8 +++++++- .../arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt | 7 ++++++- .../arflix/tv/ui/screens/settings/SettingsViewModel.kt | 4 ++-- 4 files changed, 21 insertions(+), 6 deletions(-) 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 45098c897..c45027de9 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 @@ -702,7 +702,7 @@ class HomeServerRepository @Inject constructor( private fun parseConnections(json: String?): List { if (json.isNullOrBlank()) return emptyList() - return runCatching { + return try { val root = JsonParser().parse(json) val connections = when { root.isJsonObject && root.asJsonObject.has("connections") -> { @@ -719,7 +719,11 @@ class HomeServerRepository @Inject constructor( .map { it.sanitized().decryptedForUse() } .filter { it.serverUrl.isNotBlank() || it.accessToken.isNotBlank() } .distinctBy { connectionIdentity(it) } - }.getOrDefault(emptyList()) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.recordException(e) + emptyList() + } } private fun HomeServerConnection.encryptedForStorage(): HomeServerConnection { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 330da96dd..c183ddc2b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -3042,7 +3042,7 @@ class PlayerViewModel @Inject constructor( /** Whitespace/tag-insensitive form for comparing renderer cue text against parsed file text. */ private fun normalizeCueTextForCompare(text: String): String = - text.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim() + text.replace(PlayerViewModelRegexes.HTML_TAG_REGEX, " ").replace(PlayerViewModelRegexes.MULTI_SPACE_REGEX, " ").trim() /** * A scored candidate. [offsetMs] is 0 for a normal (as-authored) match, or the uniform delay @@ -4531,3 +4531,9 @@ class PlayerViewModel @Inject constructor( ) } } + + +private object PlayerViewModelRegexes { + val HTML_TAG_REGEX = Regex("<[^>]*>") + val MULTI_SPACE_REGEX = Regex("\\s+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt index c6492cfa0..6c004f2ee 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/SubtitleSyncMatcher.kt @@ -272,7 +272,7 @@ object SubtitleSyncMatcher { else -> sb.append(' ') } } - return sb.toString().replace(Regex("\\s+"), " ").trim() + return sb.toString().replace(SubtitleSyncMatcherRegexes.MULTI_SPACE_REGEX, " ").trim() } fun cueTextAt(cues: List, timeMs: Long): String? = @@ -331,3 +331,8 @@ object SubtitleSyncMatcher { }.getOrNull() } } + + +private object SubtitleSyncMatcherRegexes { + val MULTI_SPACE_REGEX = Regex("\\s+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 69386dbec..b2a27495d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1509,7 +1509,7 @@ class SettingsViewModel @Inject constructor( fun addQualityFilter(deviceName: String, regexPattern: String) { val trimmedRegex = regexPattern.trim() if (trimmedRegex.isBlank()) return - if (runCatching { Regex(trimmedRegex) }.isFailure) return + try { Regex(trimmedRegex) } catch (e: Exception) { return } viewModelScope.launch { val next = _uiState.value.qualityFilters + QualityFilterConfig( @@ -1525,7 +1525,7 @@ class SettingsViewModel @Inject constructor( fun updateQualityFilter(filterId: String, deviceName: String, regexPattern: String) { val trimmedRegex = regexPattern.trim() if (trimmedRegex.isBlank()) return - if (runCatching { Regex(trimmedRegex) }.isFailure) return + try { Regex(trimmedRegex) } catch (e: Exception) { return } viewModelScope.launch { val next = _uiState.value.qualityFilters.map { filter -> From ae51dde4293a77a36c009f3e08ef42a678319f61 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Wed, 5 Aug 2026 21:38:22 +0530 Subject: [PATCH 8/9] Apply PR review comments across all merged PRs --- .../tv/data/repository/AuthRepository.kt | 12 ++++++--- .../tv/data/repository/IptvChannelStore.kt | 13 ++++++++-- .../tv/data/repository/IptvRepository.kt | 8 ++++-- .../repository/ProfileAvatarImageManager.kt | 5 ---- .../tv/data/repository/SportsRepository.kt | 18 ++++++------- .../data/repository/TvDeviceAuthRepository.kt | 3 +-- .../data/repository/WatchHistoryRepository.kt | 1 + .../tv/ui/screens/details/DetailsViewModel.kt | 5 +++- .../tv/ui/screens/settings/SettingsScreen.kt | 6 +++-- .../ui/screens/settings/SettingsViewModel.kt | 26 ++++++++++++++----- 10 files changed, 64 insertions(+), 33 deletions(-) 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 09201e64d..3bd06f6e4 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 @@ -1717,8 +1717,8 @@ class AuthRepository @Inject constructor( body = body ) val responseJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null } - if (responseJson?.optBoolean("accepted", true) == false) { - val reason = responseJson.optString("reason", "existing_snapshot_is_richer") + if (responseJson == null || !responseJson.optBoolean("accepted", false)) { + val reason = responseJson?.optString("reason", "invalid_response") ?: "invalid_response" throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason") } Result.success(Unit) @@ -2110,7 +2110,13 @@ class AuthRepository @Inject constructor( private fun parseInstantMillis(value: String?): Long { if (value.isNullOrBlank()) return 0L - return try { Instant.parse(value).toEpochMilliseconds() } catch (e: Exception) { 0L } + return try { + Instant.parse(value).toEpochMilliseconds() + } catch (e: IllegalArgumentException) { + 0L + } catch (e: java.time.format.DateTimeParseException) { + 0L + } } private fun encodeProfileAccountSyncPayload(existingAddons: String?, payload: String): String { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt index 398fa60b7..993c7502a 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt @@ -361,9 +361,18 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper( private fun readChannel(cursor: android.database.Cursor, c: ColumnIndices): IptvChannel { val headersJson = if (cursor.isNull(c.requestHeaders)) null else cursor.getString(c.requestHeaders) val drmJson = if (cursor.isNull(c.drm)) null else cursor.getString(c.drm) - @Suppress("UNCHECKED_CAST") val headers = headersJson?.let { - try { gson.fromJson(it, Map::class.java) as? Map } catch (e: Exception) { null } + try { + val jsonElement = com.google.gson.JsonParser.parseString(it) + if (jsonElement.isJsonObject) { + jsonElement.asJsonObject.entrySet().mapNotNull { entry -> + val value = entry.value + if (value != null && value.isJsonPrimitive && value.asJsonPrimitive.isString) { + entry.key to value.asString + } else null + }.toMap() + } else null + } catch (e: Exception) { null } }.orEmpty() val drm = drmJson?.let { try { gson.fromJson(it, DrmInfo::class.java) } catch (e: Exception) { null } } val name = cursor.getString(c.name).orEmpty() diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 9f87604a9..1b552cf7b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -1242,8 +1242,12 @@ class IptvRepository @Inject constructor( if (pattern in setOf("Y", "m", "d", "H", "M", "S")) { return@replace match.value } - runCatching { dateTime.format(IptvRepoDateRegexes.formatterFor(pattern)) } - .getOrDefault(match.value) + try { + dateTime.format(IptvRepoDateRegexes.formatterFor(pattern)) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + match.value + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt index 14f9bf949..fd823e534 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ProfileAvatarImageManager.kt @@ -230,16 +230,11 @@ class ProfileAvatarImageManager @Inject constructor( ?.optString(profileId) ?.takeIf { it.isNotBlank() } } catch (e: org.json.JSONException) { -<<<<<<< HEAD com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Error parsing inline avatar JSON: ${e.message}") null } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Unexpected error parsing inline avatar: ${e.message}") -======= - null - } catch (e: Exception) { ->>>>>>> origin/chore/auto-refactor-20231024-14946652076883075151 null } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index 4011ca797..c1cb0e981 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -38,6 +38,14 @@ class SportsRepository @Inject constructor( private val streamRepository: StreamRepository, private val streamApi: StreamApi ) { + companion object { + private const val MAX_EVENT_ITEMS = 24 + private const val MAX_CATALOGS_PER_LOAD = 3 + private const val CATEGORY_ARTWORK_TIMEOUT_MS = 1_500L + + private fun drawable(name: String): String = + "android.resource://com.arvio.tv/drawable/$name" + } data class SportsPlayback( val mediaId: Int, val title: String, @@ -366,7 +374,6 @@ class SportsRepository @Inject constructor( ) emptyList() } - } } private fun placeholderItem( @@ -622,15 +629,6 @@ class SportsRepository @Inject constructor( else -> 0L } } - - private companion object { - const val MAX_EVENT_ITEMS = 24 - const val MAX_CATALOGS_PER_LOAD = 3 - const val CATEGORY_ARTWORK_TIMEOUT_MS = 1_500L - - fun drawable(name: String): String = - "android.resource://com.arvio.tv/drawable/$name" - } } private object SportsRepoRegexes { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt index e624eaa3d..4f7e5abd8 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TvDeviceAuthRepository.kt @@ -60,7 +60,7 @@ class TvDeviceAuthRepository @Inject constructor( .post("{}".toRequestBody(jsonMediaType)) .build() - val session = okHttpClient.newCall(request).execute().use { response -> + okHttpClient.newCall(request).execute().use { response -> val body = response.body?.string().orEmpty() if (!response.isSuccessful) { throw IllegalStateException(parseError(body, context.getString(R.string.tv_link_failed_start))) @@ -196,7 +196,6 @@ class TvDeviceAuthRepository @Inject constructor( } catch (e: Exception) { fallback } - } } private fun parseStatus(body: String): TvDeviceAuthStatus { 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 4bec4158e..17660fd98 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 @@ -222,6 +222,7 @@ class WatchHistoryRepository @Inject constructor( AppLogger.e("WatchHistoryRepository", "Fallback error in watch history operation", fallbackEx) } } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Error in watch history operation", e) } 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 f3df21dfa..38ef2d58e 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 @@ -1007,7 +1007,10 @@ class DetailsViewModel @Inject constructor( duration = 0L, position = 0L ) - } catch (_: Exception) {} + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + com.arflix.tv.util.AppLogger.w("DetailsViewModel", "Failed to mark episode watched/save progress: ${e.message}") + } } else { traktRepository.markEpisodeUnwatched( currentMediaId, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 6e6197936..e6fe57992 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1984,12 +1984,14 @@ fun SettingsScreen( onDismiss = { showQualityFilterEditor = false }, onSave = { val id = editingQualityFilterId - if (id == null) { + val success = if (id == null) { viewModel.addQualityFilter(qualityFilterDeviceName, qualityFilterRegexPattern) } else { viewModel.updateQualityFilter(id, qualityFilterDeviceName, qualityFilterRegexPattern) } - showQualityFilterEditor = false + if (success) { + showQualityFilterEditor = false + } } ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index b2a27495d..0be216dc1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1506,10 +1506,16 @@ class SettingsViewModel @Inject constructor( } } - fun addQualityFilter(deviceName: String, regexPattern: String) { + fun addQualityFilter(deviceName: String, regexPattern: String): Boolean { val trimmedRegex = regexPattern.trim() - if (trimmedRegex.isBlank()) return - try { Regex(trimmedRegex) } catch (e: Exception) { return } + if (trimmedRegex.isBlank()) return false + try { + Regex(trimmedRegex) + } catch (_: java.util.regex.PatternSyntaxException) { + return false + } catch (_: IllegalArgumentException) { + return false + } viewModelScope.launch { val next = _uiState.value.qualityFilters + QualityFilterConfig( @@ -1520,12 +1526,19 @@ class SettingsViewModel @Inject constructor( ) saveQualityFilters(next) } + return true } - fun updateQualityFilter(filterId: String, deviceName: String, regexPattern: String) { + fun updateQualityFilter(filterId: String, deviceName: String, regexPattern: String): Boolean { val trimmedRegex = regexPattern.trim() - if (trimmedRegex.isBlank()) return - try { Regex(trimmedRegex) } catch (e: Exception) { return } + if (trimmedRegex.isBlank()) return false + try { + Regex(trimmedRegex) + } catch (_: java.util.regex.PatternSyntaxException) { + return false + } catch (_: IllegalArgumentException) { + return false + } viewModelScope.launch { val next = _uiState.value.qualityFilters.map { filter -> @@ -1540,6 +1553,7 @@ class SettingsViewModel @Inject constructor( } saveQualityFilters(next) } + return true } fun cycleQualityFilterPreset() { From b13850a8686de07336dbe0484861e67d189aee0e Mon Sep 17 00:00:00 2001 From: Arvin Date: Wed, 12 Aug 2026 14:14:05 +0200 Subject: [PATCH 9/9] fix(trakt): handle empty watchlist responses --- .../kotlin/com/arflix/tv/data/repository/TraktRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 8af477518..87c67fe8c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -2921,7 +2921,7 @@ class TraktRepository @Inject constructor( val body = response.body?.string().orEmpty() val listType = TypeToken.getParameterized(List::class.java, TraktWatchlistItem::class.java).type val items: List = try { - gson.fromJson(body, listType) + gson.fromJson>(body, listType).orEmpty() } catch (e: com.google.gson.JsonSyntaxException) { com.arflix.tv.util.AppLogger.e("Trakt", "Failed to parse watchlist items: ${e.message}") emptyList()