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/AuthRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt index e91f02732..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 @@ -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,9 +1716,9 @@ class AuthRepository @Inject constructor( url = Constants.NETLIFY_ACCOUNT_SYNC_PUSH_URL, body = body ) - val responseJson = runCatching { JSONObject(responseBody) }.getOrNull() - if (responseJson?.optBoolean("accepted", true) == false) { - val reason = responseJson.optString("reason", "existing_snapshot_is_richer") + val responseJson = try { JSONObject(responseBody) } catch (e: org.json.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") } Result.success(Unit) @@ -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,13 @@ 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: IllegalArgumentException) { + 0L + } catch (e: java.time.format.DateTimeParseException) { + 0L + } } private fun encodeProfileAccountSyncPayload(existingAddons: String?, payload: String): String { @@ -2139,7 +2145,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/HomeServerRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/HomeServerRepository.kt index 45098c897..b38ae7860 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 { @@ -2529,7 +2533,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/IptvChannelStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvChannelStore.kt index 01cf01700..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 @@ -344,26 +344,37 @@ 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 } } 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 { - runCatching { gson.fromJson(it, Map::class.java) as? Map }.getOrNull() + 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 { 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 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..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,9 +1242,12 @@ 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) } - .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/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 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..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 @@ -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() @@ -152,7 +160,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 +181,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 +210,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 +224,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..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, @@ -340,16 +348,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=${e::class.java.simpleName}", + severity = "warning" + ) + emptyList() + } catch (e: java.io.IOException) { 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: 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( @@ -605,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/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 6208ce551..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 @@ -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).orEmpty() + } 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( @@ -4455,7 +4496,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..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 @@ -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,29 @@ 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: 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 +101,7 @@ class TvDeviceAuthRepository @Inject constructor( .post(payload.toRequestBody(jsonMediaType)) .build() - okHttpClient.newCall(statusRequest).execute().use { response -> + okHttpClient.newCall(statusRequest).execute().use outerUse@{ response -> val body = response.body?.string().orEmpty() if (response.code == 404) { // Backward compatibility for older deployments still using /tv-auth-poll @@ -108,14 +116,22 @@ class TvDeviceAuthRepository @Inject constructor( if (!fallback.isSuccessful) { throw IllegalStateException(parseError(fallbackBody, context.getString(R.string.tv_link_failed_poll))) } - return@use parseStatus(fallbackBody) + return@outerUse 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: 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 +149,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 +169,33 @@ 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: 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 { 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 fb0ef1161..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 @@ -193,7 +193,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 } @@ -206,14 +211,18 @@ 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) { + if (e is kotlinx.coroutines.CancellationException) throw e AppLogger.e("WatchHistoryRepository", "Error in watch history operation", e) } @@ -240,7 +249,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) + } } } 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/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/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/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 69386dbec..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 - if (runCatching { Regex(trimmedRegex) }.isFailure) 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 - if (runCatching { Regex(trimmedRegex) }.isFailure) 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() {