diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt index ef41a95ca..7788e295b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt +++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt @@ -5,6 +5,7 @@ import android.app.Application import android.content.ComponentCallbacks2 import android.graphics.Bitmap import android.os.Build +import android.util.Log import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import androidx.work.ExistingPeriodicWorkPolicy @@ -146,7 +147,13 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact // Wait for first navigation/auth restore work to start so the // event can include account context without delaying app launch. delay(3_000L) - runCatching { appUsageAnalyticsRepository.recordAppOpen() } + try { + appUsageAnalyticsRepository.recordAppOpen() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Log.w("ArflixApplication", "Failed to record app open analytics", e) + } } // Observe auth state: start realtime on login, stop on logout diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AppUsageAnalyticsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AppUsageAnalyticsRepository.kt index 75ab04e31..43db829d3 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AppUsageAnalyticsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AppUsageAnalyticsRepository.kt @@ -38,11 +38,11 @@ class AppUsageAnalyticsRepository @Inject constructor( (Constants.SUPABASE_URL.isBlank() || Constants.SUPABASE_ANON_KEY.isBlank()) ) return@withContext - runCatching { + try { val now = System.currentTimeMillis() val lastSentAt = context.settingsDataStore.data.first()[LAST_APP_OPEN_SENT_AT_KEY] ?: 0L if (now - lastSentAt < APP_OPEN_MIN_INTERVAL_MS) { - return@runCatching + return@withContext } withTimeoutOrNull(AUTH_STATE_WAIT_MS) { @@ -50,10 +50,18 @@ class AppUsageAnalyticsRepository @Inject constructor( } val installId = getOrCreateInstallId() - val accessToken = runCatching { authRepository.getAccessToken() }.getOrNull().orEmpty() - val profileId = runCatching { profileManager.getProfileId() } - .getOrDefault(profileManager.getProfileIdSync()) - .takeIf { it.isNotBlank() } + val accessToken = try { + authRepository.getAccessToken() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + "" + } + val profileId = try { + profileManager.getProfileId() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + profileManager.getProfileIdSync() + }.takeIf { it.isNotBlank() } val deviceType = detectDeviceType(context).name.lowercase() val metadata = JSONObject() @@ -87,7 +95,7 @@ class AppUsageAnalyticsRepository @Inject constructor( requestBuilder .header("apikey", Constants.SUPABASE_ANON_KEY) .header("Authorization", "Bearer ${Constants.SUPABASE_ANON_KEY}") - if (accessToken.isNotBlank()) { + if (!accessToken.isNullOrBlank()) { requestBuilder.header("x-user-token", accessToken) } } else { @@ -105,8 +113,11 @@ class AppUsageAnalyticsRepository @Inject constructor( context.settingsDataStore.edit { prefs -> prefs[LAST_APP_OPEN_SENT_AT_KEY] = now } - }.onFailure { error -> - AppLogger.w(TAG, "App usage analytics event failed", error) + } catch (e: java.io.IOException) { + AppLogger.w(TAG, "App usage analytics event failed (Network)", e) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + AppLogger.w(TAG, "App usage analytics event failed", e) } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt index ba84b9219..ddd3264d6 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogDiscoveryRepository.kt @@ -28,8 +28,18 @@ class CatalogDiscoveryRepository @Inject constructor( return@withContext Result.success(emptyList()) } - val trakt = runCatching { searchTraktLists(normalizedQuery) } - val mdblist = runCatching { searchMdblistLists(normalizedQuery) } + val trakt = try { + Result.success(searchTraktLists(normalizedQuery)) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) + } + val mdblist = try { + Result.success(searchMdblistLists(normalizedQuery)) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Result.failure(e) + } val combined = (trakt.getOrDefault(emptyList()) + mdblist.getOrDefault(emptyList())) .distinctBy { it.sourceUrl.lowercase() } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index aee904ac6..35aa2abbe 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -1178,20 +1178,30 @@ class CatalogRepository @Inject constructor( val collectionHeroVideoUrl = asTrimmedString(row["collectionHeroVideoUrl"]) val collectionTileShape = parseCollectionTileShapeCompat(asTrimmedString(row["collectionTileShape"])) val collectionHideTitle = (row["collectionHideTitle"] as? Boolean) ?: false - val collectionSources = runCatching { + val collectionSources = try { val jsonValue = gson.toJson(row["collectionSources"]) gson.fromJson>( jsonValue, TypeToken.getParameterized(List::class.java, CollectionSourceConfig::class.java).type ) ?: emptyList() - }.getOrDefault(emptyList()) - val requiredAddonUrls = runCatching { + } catch (e: com.google.gson.JsonSyntaxException) { + emptyList() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptyList() + } + val requiredAddonUrls = try { val jsonValue = gson.toJson(row["requiredAddonUrls"]) gson.fromJson>( jsonValue, TypeToken.getParameterized(List::class.java, String::class.java).type )?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() - }.getOrDefault(emptyList()) + } catch (e: com.google.gson.JsonSyntaxException) { + emptyList() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + emptyList() + } val sourceTypeRaw = (row["sourceType"] as? String)?.trim().orEmpty() val sourceType = parseSourceTypeCompat(sourceTypeRaw, sourceUrl, sourceRef) val isPreinstalledRaw = (row["isPreinstalled"] as? Boolean) ?: false @@ -1237,11 +1247,18 @@ class CatalogRepository @Inject constructor( val normalizedRef = config.sourceRef?.trim().takeUnless { it.isNullOrBlank() } val bundledPreinstalled = isBundledPreinstalledCatalogId(config.id) val sourceRefAddon = parseAddonSourceRef(normalizedRef) - val sourceTypeName = runCatching { config.sourceType.name } - .getOrDefault(CatalogSourceType.PREINSTALLED.name) - val configKind = runCatching { config.kind.name } - .mapCatching { CatalogKind.valueOf(it) } - .getOrDefault(CatalogKind.STANDARD) + val sourceTypeName = try { + config.sourceType.name + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + CatalogSourceType.PREINSTALLED.name + } + val configKind = try { + CatalogKind.valueOf(config.kind.name) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + CatalogKind.STANDARD + } val safeCollectionSources = config.collectionSources.orEmpty() val safeRequiredAddonUrls = config.requiredAddonUrls.orEmpty() val normalizedCollectionTileShape = try { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt index 01eba98e5..2774c115e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt @@ -1,6 +1,7 @@ package com.arflix.tv.data.repository import android.util.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -30,12 +31,29 @@ class CloudSyncCoordinator @Inject constructor( private val started = AtomicBoolean(false) + private suspend fun getSyncUserIdOrNull(): String? { + return try { + authRepository.getCurrentUserIdForSync() + } catch (e: CancellationException) { + throw e + } catch (e: retrofit2.HttpException) { + Log.w("CloudSyncCoordinator", "HTTP error retrieving sync user ID", e) + null + } catch (e: java.io.IOException) { + Log.w("CloudSyncCoordinator", "Network error retrieving sync user ID", e) + null + } catch (e: Exception) { + Log.w("CloudSyncCoordinator", "Failed to retrieve sync user ID", e) + null + } + } + fun start() { synchronized(lifecycleLock) { if (!started.compareAndSet(false, true)) return collectorJob = scope.launch { invalidationBus.events.collectLatest { invalidation -> - val userId = try { authRepository.getCurrentUserIdForSync() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { null } + val userId = getSyncUserIdOrNull() if (userId.isNullOrBlank()) { cloudSyncRepository.markLocalStateDirtyNow() CloudSyncWorker.enqueueRecovery(context) @@ -76,7 +94,7 @@ class CloudSyncCoordinator @Inject constructor( } delay(backoffMs) - val userId = try { authRepository.getCurrentUserIdForSync() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { null } + val userId = getSyncUserIdOrNull() if (userId.isNullOrBlank()) { cloudSyncRepository.markLocalStateDirtyNow() CloudSyncWorker.enqueueRecovery(context) @@ -84,12 +102,23 @@ class CloudSyncCoordinator @Inject constructor( return@launch } Log.i("CloudSyncCoordinator", "Flushing cloud sync for ${invalidation.scope}") - try { cloudSyncRepository.pushToCloud(force = true); Result.success(Unit) } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { Result.failure(e) } - .onFailure { error -> - Log.w("CloudSyncCoordinator", "Cloud push failed after ${invalidation.scope}: ${error.message}") - cloudSyncRepository.markLocalStateDirty() - CloudSyncWorker.enqueueRecovery(context) - } + try { + cloudSyncRepository.pushToCloud(force = true) + } catch (e: CancellationException) { + throw e + } catch (e: retrofit2.HttpException) { + Log.w("CloudSyncCoordinator", "HTTP error during cloud push for ${invalidation.scope}: ${e.message}") + cloudSyncRepository.markLocalStateDirty() + CloudSyncWorker.enqueueRecovery(context) + } catch (e: java.io.IOException) { + Log.w("CloudSyncCoordinator", "Network error during cloud push for ${invalidation.scope}: ${e.message}") + cloudSyncRepository.markLocalStateDirty() + CloudSyncWorker.enqueueRecovery(context) + } catch (error: Exception) { + Log.w("CloudSyncCoordinator", "Cloud push failed after ${invalidation.scope}: ${error.message}") + cloudSyncRepository.markLocalStateDirty() + CloudSyncWorker.enqueueRecovery(context) + } } } } 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 65a1daf46..45098c897 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 @@ -117,14 +117,14 @@ internal data class HomeServerCandidateInfo( internal object HomeServerMatcher { fun normalizeTitle(title: String): String { val ascii = Normalizer.normalize(title, Normalizer.Form.NFD) - .replace(DIACRITICS_REGEX, "") + .replace(HomeServerRegexes.DIACRITICS_REGEX, "") return ascii .lowercase(Locale.US) .replace("&", " and ") - .replace(NON_ALPHA_NUM_REGEX, " ") - .replace(ARTICLES_REGEX, " ") + .replace(HomeServerRegexes.NON_ALPHA_NUM_REGEX, " ") + .replace(HomeServerRegexes.ARTICLES_REGEX, " ") .trim() - .replace(MULTI_SPACE_REGEX, " ") + .replace(HomeServerRegexes.MULTI_SPACE_REGEX, " ") } fun score( @@ -765,7 +765,7 @@ class HomeServerRepository @Inject constructor( private fun createConnectionId(serverUrl: String, kind: HomeServerKind, userIdentity: String): String { return "${kind.name}:${serverUrl.trimEnd('/').lowercase(Locale.US)}:${userIdentity.lowercase(Locale.US)}" - .replace(CONNECTION_ID_SANITIZER_REGEX, "_") + .replace(HomeServerRegexes.CONNECTION_ID_SANITIZER_REGEX, "_") } private fun connectionIdentity(connection: HomeServerConnection): String { @@ -1001,7 +1001,11 @@ class HomeServerRepository @Inject constructor( } private fun fetchPublicInfo(serverUrl: String): ServerInfo { - val info = runCatching { getJson(buildUrl(serverUrl, "/System/Info/Public")) }.getOrNull() + val info = try { + getJson(buildUrl(serverUrl, "/System/Info/Public")) + } catch (e: Exception) { + null + } if (info != null && info.entrySet().isNotEmpty()) { return ServerInfo( serverName = info.string("ServerName"), @@ -1011,7 +1015,11 @@ class HomeServerRepository @Inject constructor( ) } - val plexIdentity = runCatching { getText(buildUrl(serverUrl, "/identity")) }.getOrNull() + val plexIdentity = try { + getText(buildUrl(serverUrl, "/identity")) + } catch (e: Exception) { + null + } val (plexName, plexId) = parsePlexIdentity(plexIdentity.orEmpty()) return ServerInfo( serverName = plexName, @@ -1159,7 +1167,7 @@ class HomeServerRepository @Inject constructor( accountToken = trimmedAccountToken, lastConnectedAt = System.currentTimeMillis() ) - val info = runCatching { fetchSystemInfo(candidate) } + val info = try { Result.success(fetchSystemInfo(candidate)) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; Result.failure(e) } .getOrElse { error -> lastError = error null @@ -1181,7 +1189,7 @@ class HomeServerRepository @Inject constructor( serverId = info.serverId.ifBlank { candidate.serverId }, lastConnectedAt = System.currentTimeMillis() ) - val collections = runCatching { fetchCollections(shell) } + val collections = try { Result.success(fetchCollections(shell)) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; Result.failure(e) } .getOrElse { error -> lastError = error emptyList() @@ -1219,7 +1227,7 @@ class HomeServerRepository @Inject constructor( } private fun parsePlexResourcesXml(xml: String): List { - return PLEX_DEVICE_REGEX.findAll(xml) + return HomeServerRegexes.PLEX_DEVICE_REGEX.findAll(xml) .map { match -> val attrs = match.groupValues.getOrNull(1).orEmpty() .ifBlank { match.groupValues.getOrNull(3).orEmpty() } @@ -1231,7 +1239,7 @@ class HomeServerRepository @Inject constructor( clientIdentifier = attrs.xmlAttribute("clientIdentifier"), accessToken = attrs.xmlAttribute("accessToken"), owned = attrs.xmlBooleanAttribute("owned"), - connections = PLEX_CONNECTION_REGEX.findAll(body) + connections = HomeServerRegexes.PLEX_CONNECTION_REGEX.findAll(body) .map { connection -> val connectionAttrs = connection.groupValues.getOrNull(1).orEmpty() PlexResourceConnection( @@ -2204,7 +2212,7 @@ class HomeServerRepository @Inject constructor( ?.trim() ?.lowercase(Locale.US) ?.replace("matroska", "mkv") - ?.replace(NON_ALPHA_NUM_STRICT_REGEX, "") + ?.replace(HomeServerRegexes.NON_ALPHA_NUM_STRICT_REGEX, "") .orEmpty() return normalized.takeIf { it.isNotBlank() && it.length <= 5 } } @@ -2403,12 +2411,11 @@ class HomeServerRepository @Inject constructor( takeIf { it.isJsonObject }?.asJsonObject private fun JsonElement.asStringOrNull(): String? = - runCatching { takeUnless { it.isJsonNull }?.asString }.getOrNull() + try { takeUnless { it.isJsonNull }?.asString } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e; null } - private val xmlAttributeRegexCache = java.util.concurrent.ConcurrentHashMap() private fun String.xmlAttribute(name: String): String { - val pattern = xmlAttributeRegexCache.getOrPut(name) { Regex("""\b${Regex.escape(name)}=["']([^"']*)["']""") } + val pattern = HomeServerXmlRegexCache.getRegex(name) return pattern.find(this)?.groupValues?.getOrNull(1).orEmpty().xmlDecoded() } @@ -2509,17 +2516,6 @@ class HomeServerRepository @Inject constructor( } -private val DIACRITICS_REGEX = Regex("\\p{Mn}+") -private val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") -private val ARTICLES_REGEX = Regex("\\b(the|a|an)\\b") -private val MULTI_SPACE_REGEX = Regex("\\s+") -private val CONNECTION_ID_SANITIZER_REGEX = Regex("[^a-z0-9:._-]+") -private val NON_ALPHA_NUM_STRICT_REGEX = Regex("[^a-z0-9]") -private val PLEX_DEVICE_REGEX = Regex( - """]*)>(.*?)|]*)/>""", - setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) -) -private val PLEX_CONNECTION_REGEX = Regex("""]*)/?\s*>""", RegexOption.IGNORE_CASE) @@ -2533,3 +2529,16 @@ private object HomeServerXmlRegexCache { } } } +private 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") + val MULTI_SPACE_REGEX = Regex("\\s+") + val CONNECTION_ID_SANITIZER_REGEX = Regex("[^a-z0-9:._-]+") + val NON_ALPHA_NUM_STRICT_REGEX = Regex("[^a-z0-9]") + val PLEX_DEVICE_REGEX = Regex( + """]*)>(.*?)|]*)/>""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) +) + val PLEX_CONNECTION_REGEX = Regex("""]*)/?\s*>""", RegexOption.IGNORE_CASE) +} 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 9fa684f69..08d0becd5 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 @@ -98,6 +98,16 @@ private object IptvRepoDateRegexes { val YEAR_PATTERN: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy") val MONTH_PATTERN: DateTimeFormatter = DateTimeFormatter.ofPattern("MM") val DAY_PATTERN: DateTimeFormatter = DateTimeFormatter.ofPattern("dd") + + + + private val datePatternRegexCache = java.util.concurrent.ConcurrentHashMap() + + fun getDatePatternRegex(key: String): Regex { + return datePatternRegexCache.getOrPut(key) { + Regex("""\$\{$key:([^}]+)\}|\{$key:([^}]+)\}""") + } + } val HOUR_PATTERN: DateTimeFormatter = DateTimeFormatter.ofPattern("HH") val MIN_PATTERN: DateTimeFormatter = DateTimeFormatter.ofPattern("mm") val SEC_PATTERN: DateTimeFormatter = DateTimeFormatter.ofPattern("ss") @@ -1221,10 +1231,10 @@ class IptvRepository @Inject constructor( } } - private val datePatternRegexCache = java.util.concurrent.ConcurrentHashMap() + private fun String.replaceDatePatternPlaceholders(key: String, dateTime: LocalDateTime): String { - val regex = datePatternRegexCache.getOrPut(key) { Regex("""\$\{""" + key + """:([^}]+)\}|\{""" + key + """:([^}]+)\}""") } + val regex = IptvRepoDateRegexes.getDatePatternRegex(key) return regex.replace(this) { match -> val pattern = match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvUrlNormalizer.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvUrlNormalizer.kt index 8edcdab92..908493cf5 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvUrlNormalizer.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvUrlNormalizer.kt @@ -39,7 +39,9 @@ private fun decodeLegacyHttpUrl(value: String): String? { .mapNotNull { flags -> try { String(Base64.decode(value, flags), StandardCharsets.UTF_8).trim() - } catch (e: Exception) { null } + } catch (e: IllegalArgumentException) { + null + } } .firstOrNull { decoded -> decoded.startsWith("http://", ignoreCase = true) || diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt index fdcb09e29..a5e4342cc 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt @@ -440,13 +440,19 @@ class RealtimeSyncManager @Inject constructor( pendingPullJob = scope.launch { delay(DEBOUNCE_MS) Log.i(TAG, "Pulling cloud state after realtime notification") - runCatching { cloudSyncRepository.pullFromCloud() } - .onSuccess { result -> - if (result == CloudSyncRepository.RestoreResult.RESTORED) { - _accountSyncEvents.tryEmit(Unit) - } + try { + val result = cloudSyncRepository.pullFromCloud() + if (result == CloudSyncRepository.RestoreResult.RESTORED) { + _accountSyncEvents.tryEmit(Unit) } - .onFailure { Log.w(TAG, "Realtime pull failed: ${it.message}") } + } catch (e: retrofit2.HttpException) { + Log.w(TAG, "Realtime pull failed (HTTP): ${e.message}") + } catch (e: java.io.IOException) { + Log.w(TAG, "Realtime pull failed (Network): ${e.message}") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "Realtime pull failed: ${e.message}") + } } } @@ -518,15 +524,31 @@ class RealtimeSyncManager @Inject constructor( // pushToCloud, so there's no retry loop risk. if (cloudSyncRepository.isPushDirty) { Log.i(TAG, "Periodic sync: retrying dirty push") - runCatching { cloudSyncRepository.pushToCloud() } - .onFailure { - Log.w(TAG, "Dirty push retry failed: ${it.message}") - com.arflix.tv.worker.CloudSyncWorker.enqueueRecovery(context) - } + try { + cloudSyncRepository.pushToCloud() + } catch (e: retrofit2.HttpException) { + Log.w(TAG, "Dirty push retry failed (HTTP): ${e.message}") + com.arflix.tv.worker.CloudSyncWorker.enqueueRecovery(context) + } catch (e: java.io.IOException) { + Log.w(TAG, "Dirty push retry failed (Network): ${e.message}") + com.arflix.tv.worker.CloudSyncWorker.enqueueRecovery(context) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "Dirty push retry failed: ${e.message}") + com.arflix.tv.worker.CloudSyncWorker.enqueueRecovery(context) + } } Log.d(TAG, "Periodic sync tick") - runCatching { cloudSyncRepository.pullFromCloud() } - .onFailure { Log.w(TAG, "Periodic sync failed: ${it.message}") } + try { + cloudSyncRepository.pullFromCloud() + } catch (e: retrofit2.HttpException) { + Log.w(TAG, "Periodic sync failed (HTTP): ${e.message}") + } catch (e: java.io.IOException) { + Log.w(TAG, "Periodic sync failed (Network): ${e.message}") + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "Periodic sync failed: ${e.message}") + } } } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 8beb80467..35a632844 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -421,7 +421,7 @@ class StreamRepository @Inject constructor( ).orEmpty() .filter { it.enabled && it.regexPattern.isNotBlank() } val regexes = filters.mapNotNull { filter -> - try { Regex(filter.regexPattern, RegexOption.IGNORE_CASE) } catch (e: Exception) { null } + try { StreamRepoRegexes.getOrPutFilterRegex(filter.regexPattern) } catch (e: Exception) { null } } cachedQualityFilters = PrecompiledQualityFilter(regexes, isEmpty = regexes.isEmpty()) } @@ -438,7 +438,7 @@ class StreamRepository @Inject constructor( fun updateQualityFiltersCache(filters: List) { val enabledFilters = filters.filter { it.enabled && it.regexPattern.isNotBlank() } val regexes = enabledFilters.mapNotNull { filter -> - try { Regex(filter.regexPattern, RegexOption.IGNORE_CASE) } catch (e: Exception) { null } + try { StreamRepoRegexes.getOrPutFilterRegex(filter.regexPattern) } catch (e: Exception) { null } } cachedQualityFilters = PrecompiledQualityFilter(regexes, isEmpty = regexes.isEmpty()) synchronized(streamResultCache) { streamResultCache.clear() } 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 71c2b0f28..8d91214b4 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 @@ -1981,7 +1981,13 @@ class TraktRepository @Inject constructor( // The previous code used refreshTokenIfNeeded() == null which could // incorrectly trigger for Trakt users on network errors, polluting // the Trakt CW cache with local-only items. - val isTraktAuth = try { isAuthenticated.first() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { false } + val isTraktAuth = try { + isAuthenticated.first() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } if (!isTraktAuth) { cachedContinueWatching = trimmed } @@ -2328,7 +2334,13 @@ class TraktRepository @Inject constructor( cachedContinueWatching = cachedContinueWatching.filterNot { it.id == item.id && it.mediaType == item.mediaType } - val activeProfileId = try { profileManager.getProfileIdSync() } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { null } + val activeProfileId = try { + profileManager.getProfileIdSync() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + null + } if (!activeProfileId.isNullOrBlank()) { preloadedProfileCache[activeProfileId] = preloadedProfileCache[activeProfileId] ?.filterNot { it.id == item.id && it.mediaType == item.mediaType } @@ -2713,7 +2725,12 @@ class TraktRepository @Inject constructor( private fun parseTraktListedAtMs(value: String?): Long { if (value.isNullOrBlank()) return 0L - return runCatching { java.time.Instant.parse(value).toEpochMilli() }.getOrDefault(0L) + try { + return java.time.Instant.parse(value).toEpochMilli() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + return 0L + } } private suspend fun resolveWatchlistMovieTmdbId(movie: TraktMovieInfo): Int? { @@ -2830,6 +2847,16 @@ class TraktRepository @Inject constructor( ) } + private suspend inline fun tmdbOrNull(crossinline block: suspend () -> T): T? { + return try { + block() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } + } + private suspend fun resolveWatchlistMovieDetails(movie: TraktMovieInfo): TmdbMovieDetails? { val imdbId = movie.ids.imdb?.trim()?.takeIf { it.isNotEmpty() } val ids = buildList { @@ -2844,7 +2871,7 @@ class TraktRepository @Inject constructor( val exactIdMatches = mutableListOf() for (id in ids) { - val details = runCatching { tmdbApi.getMovieDetails(id, Constants.TMDB_API_KEY) }.getOrNull() ?: continue + val details = tmdbOrNull { tmdbApi.getMovieDetails(id, Constants.TMDB_API_KEY) } ?: continue val sameTitle = isSameWatchlistTitle(movie.title, details.title) || details.originalTitle?.let { isSameWatchlistTitle(movie.title, it) } == true val sameYear = yearCompatible(movie.year, details.releaseDate?.take(4)?.toIntOrNull()) @@ -2863,14 +2890,14 @@ class TraktRepository @Inject constructor( val searchMatch = searchTmdbWatchlistMatch(movie.title, movie.year, MediaType.MOVIE) if (searchMatch != null) { - return runCatching { tmdbApi.getMovieDetails(searchMatch, Constants.TMDB_API_KEY) }.getOrNull() + return tmdbOrNull { tmdbApi.getMovieDetails(searchMatch, Constants.TMDB_API_KEY) } } return if (movie.year == null) { exactIdMatches.firstOrNull() } else if (normalizeWatchlistTitle(movie.title).isBlank()) { ids.firstNotNullOfOrNull { id -> - runCatching { tmdbApi.getMovieDetails(id, Constants.TMDB_API_KEY) }.getOrNull() + tmdbOrNull { tmdbApi.getMovieDetails(id, Constants.TMDB_API_KEY) } } } else { null @@ -2900,7 +2927,7 @@ class TraktRepository @Inject constructor( val exactIdMatches = mutableListOf() for (id in ids) { - val details = runCatching { tmdbApi.getTvDetails(id, Constants.TMDB_API_KEY) }.getOrNull() ?: continue + val details = tmdbOrNull { tmdbApi.getTvDetails(id, Constants.TMDB_API_KEY) } ?: continue val sameTitle = isSameWatchlistTitle(show.title, details.name) || details.originalName?.let { isSameWatchlistTitle(show.title, it) } == true val sameYear = yearCompatible(show.year, details.firstAirDate?.take(4)?.toIntOrNull()) @@ -2924,14 +2951,14 @@ class TraktRepository @Inject constructor( allowTitleOnly = ids.isEmpty() ) if (searchMatch != null) { - return runCatching { tmdbApi.getTvDetails(searchMatch, Constants.TMDB_API_KEY) }.getOrNull() + return tmdbOrNull { tmdbApi.getTvDetails(searchMatch, Constants.TMDB_API_KEY) } } return if (show.year == null) { exactIdMatches.firstOrNull() } else if (normalizeWatchlistTitle(show.title).isBlank()) { ids.firstNotNullOfOrNull { id -> - runCatching { tmdbApi.getTvDetails(id, Constants.TMDB_API_KEY) }.getOrNull() + tmdbOrNull { tmdbApi.getTvDetails(id, Constants.TMDB_API_KEY) } } } else { null diff --git a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSearchMatcher.kt b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSearchMatcher.kt index 5867358c8..e0f7965c3 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSearchMatcher.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramSearchMatcher.kt @@ -170,7 +170,7 @@ class TelegramSearchMatcher @Inject constructor() { private fun cleanTitle(title: String): String { val stripped = title.replace(":", "").replace(" ", " ").trim() return java.text.Normalizer.normalize(stripped, java.text.Normalizer.Form.NFKD) - .replace("\\p{Mn}+".toRegex(), "") + .replace(TelegramSearchMatcherRegexes.DIACRITICS_REGEX, "") } private fun normalize(text: String): String = @@ -180,3 +180,7 @@ class TelegramSearchMatcher @Inject constructor() { .trim() .lowercase() } + +private object TelegramSearchMatcherRegexes { + val DIACRITICS_REGEX = Regex("\\p{Mn}+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 4110bd16a..9abab1827 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -506,9 +506,14 @@ class HomeViewModel @Inject constructor( return base } - val searchMatches = runCatching { mediaRepository.search(item.title) }.getOrDefault(emptyList()) + val searchMatches = try { + mediaRepository.search(item.title) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + emptyList() + } val bestMatch = searchMatches - .asSequence() .filter { match -> match.overview.isNotBlank() } .maxByOrNull { match -> val titleBonus = if (match.title.equals(item.title, ignoreCase = true)) 1_000 else 0 @@ -1903,8 +1908,11 @@ class HomeViewModel @Inject constructor( // Don't restart if already running if (cwFetchJob?.isActive == true) return cwFetchJob = viewModelScope.launch(Dispatchers.IO) { - val cachedResult = runCatching { preloadStartupContinueWatchingItems() } - cachedResult.onFailure { error -> + val cached = try { + preloadStartupContinueWatchingItems() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (error: Exception) { AppLogger.recordException( throwable = error, context = mapOf( @@ -1912,8 +1920,8 @@ class HomeViewModel @Inject constructor( "cw_phase" to "preload_startup" ) ) + emptyList() } - val cached = cachedResult.getOrDefault(emptyList()) if (cached.isNotEmpty()) { publishContinueWatching(cached) } @@ -1922,8 +1930,11 @@ class HomeViewModel @Inject constructor( // immediately. Avoids the 30–60s cold-refresh wait that used to // leave the CW row empty for minutes (especially when the Trakt // progress endpoint throttles with HTTP 429). - val instantResult = runCatching { resolveContinueWatchingItemsStable(forceFresh = false) } - instantResult.onFailure { error -> + val instant = try { + resolveContinueWatchingItemsStable(forceFresh = false) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (error: Exception) { AppLogger.recordException( throwable = error, context = mapOf( @@ -1931,8 +1942,8 @@ class HomeViewModel @Inject constructor( "cw_phase" to "instant" ) ) + emptyList() } - val instant = instantResult.getOrDefault(emptyList()) if (instant.isNotEmpty()) { publishContinueWatching(instant) } @@ -1940,8 +1951,11 @@ class HomeViewModel @Inject constructor( // SLOW PATH — do a freshness refresh in the background. If it // returns something different, republish. Swallows transient // Trakt 429s so the visible row doesn't blink back to empty. - val freshResult = runCatching { resolveContinueWatchingItemsStable(forceFresh = true) } - freshResult.onFailure { error -> + val fresh = try { + resolveContinueWatchingItemsStable(forceFresh = true) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (error: Exception) { AppLogger.recordException( throwable = error, context = mapOf( @@ -1949,12 +1963,18 @@ class HomeViewModel @Inject constructor( "cw_phase" to "fresh" ) ) + emptyList() } - val fresh = freshResult.getOrDefault(emptyList()) if (fresh.isNotEmpty() && fresh != instant) { publishContinueWatching(fresh) } - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) + val traktConnected = try { + traktRepository.hasTrakt() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } if (traktConnected && cached.isEmpty() && instant.isEmpty() && fresh.isEmpty()) { AppLogger.breadcrumb( tag = "ContinueWatching", @@ -3065,7 +3085,13 @@ class HomeViewModel @Inject constructor( } private suspend fun resolveContinueWatchingItems(forceFresh: Boolean): List { - val isTraktAuthenticated = runCatching { traktRepository.isAuthenticated.first() }.getOrDefault(false) + val isTraktAuthenticated = try { + traktRepository.isAuthenticated.first() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } // Debug: write CW state to a file we can pull via adb val items: List = if (isTraktAuthenticated) { // When connected to Trakt, use ONLY Trakt as the source of truth for @@ -3330,9 +3356,9 @@ class HomeViewModel @Inject constructor( ) } traktRepository.enrichContinueWatchingItems(mapped) - } catch (e: Exception) { - if (e is CancellationException) throw e - + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { emptyList() } } @@ -3377,48 +3403,66 @@ class HomeViewModel @Inject constructor( ) } traktRepository.enrichContinueWatchingItems(mapped) - } catch (e: Exception) { - if (e is CancellationException) throw e - + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { emptyList() } } private suspend fun resolveContinueWatchingItemsStable(forceFresh: Boolean): List { - val isTraktAuthenticated = runCatching { traktRepository.isAuthenticated.first() }.getOrDefault(false) + val isTraktAuthenticated = try { + traktRepository.isAuthenticated.first() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } val items = if (isTraktAuthenticated) { val traktItems = if (forceFresh) { - runCatching { traktRepository.getContinueWatching(forceRefresh = true) } - .onFailure { error -> - AppLogger.recordException( - throwable = error, - context = mapOf( - "error_area" to "ContinueWatching", - "cw_phase" to "trakt_fresh", - "force_fresh" to forceFresh.toString() - ) + try { + traktRepository.getContinueWatching(forceRefresh = true) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (error: Exception) { + AppLogger.recordException( + throwable = error, + context = mapOf( + "error_area" to "ContinueWatching", + "cw_phase" to "trakt_fresh", + "force_fresh" to forceFresh.toString() ) - } - .getOrDefault(emptyList()) + ) + emptyList() + } } else { val cached = traktRepository.getCachedContinueWatching() if (cached.isNotEmpty()) { cached } else { - runCatching { traktRepository.getContinueWatching() } - .onFailure { error -> - AppLogger.recordException( - throwable = error, - context = mapOf( - "error_area" to "ContinueWatching", - "cw_phase" to "trakt_cached_miss" - ) + try { + traktRepository.getContinueWatching() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (error: Exception) { + AppLogger.recordException( + throwable = error, + context = mapOf( + "error_area" to "ContinueWatching", + "cw_phase" to "trakt_cached_miss" ) - } - .getOrDefault(emptyList()) + ) + emptyList() + } } } - val localItems = runCatching { traktRepository.getLocalContinueWatching() }.getOrDefault(emptyList()) + val localItems = try { + traktRepository.getLocalContinueWatching() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + emptyList() + } val historyItems = loadContinueWatchingFromHistoryStable() if (traktItems.isEmpty() && historyItems.isNotEmpty()) { historyItems @@ -3434,13 +3478,23 @@ class HomeViewModel @Inject constructor( if (historyItems.isNotEmpty()) { historyItems } else { - runCatching { traktRepository.getLocalContinueWatching() }.getOrDefault(emptyList()) + try { + traktRepository.getLocalContinueWatching() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + emptyList() + } } } - val persistedDismissedKeys = runCatching { + val persistedDismissedKeys = try { traktRepository.getDismissedContinueWatchingShowKeys() - }.getOrDefault(emptySet()) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + emptySet() + } val repairedItems = repairContinueWatchingMetadataIfNeeded(items) return sanitizeContinueWatchingItems(repairedItems) @@ -3455,7 +3509,13 @@ class HomeViewModel @Inject constructor( } private suspend fun preloadStartupContinueWatchingItems(): List { - val isTraktAuthenticated = runCatching { traktRepository.isAuthenticated.first() }.getOrDefault(false) + val isTraktAuthenticated = try { + traktRepository.isAuthenticated.first() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } val items = if (isTraktAuthenticated) { try { traktRepository.preloadContinueWatchingCache() @@ -3476,13 +3536,23 @@ class HomeViewModel @Inject constructor( if (historyItems.isNotEmpty()) { historyItems } else { - runCatching { traktRepository.getLocalContinueWatching() }.getOrDefault(emptyList()) + try { + traktRepository.getLocalContinueWatching() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + emptyList() + } } } - val persistedDismissedKeys = runCatching { + val persistedDismissedKeys = try { traktRepository.getDismissedContinueWatchingShowKeys() - }.getOrDefault(emptySet()) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + emptySet() + } val repairedItems = repairContinueWatchingMetadataIfNeeded(items) return sanitizeContinueWatchingItems(repairedItems) @@ -4095,7 +4165,13 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { try { val isInWatchlist = watchlistRepository.isInWatchlist(item.mediaType, item.id) - val traktConnected = runCatching { traktRepository.hasTrakt() }.getOrDefault(false) + val traktConnected = try { + traktRepository.hasTrakt() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + false + } if (isInWatchlist) { if (traktConnected && !traktRepository.removeFromWatchlist(item.mediaType, item.id)) { throw IllegalStateException("Failed to remove from Trakt watchlist") @@ -4123,6 +4199,8 @@ class HomeViewModel @Inject constructor( toastMessage = if (isInWatchlist) context.getString(R.string.watchlist_toast_removed) else context.getString(R.string.added_to_watchlist), toastType = ToastType.SUCCESS ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { if (e is CancellationException) throw e AppLogger.recordException( 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 105fe0ccd..c66972a10 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 @@ -4396,7 +4396,7 @@ class PlayerViewModel @Inject constructor( private fun normalizeSourceTitle(value: String): String { return value .trim() - .replace(Regex("""\s+"""), " ") + .replace(PlayerRegexes.WHITESPACE, " ") .lowercase() } diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/CatalogRepositoryLegacyCompatibilityTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/CatalogRepositoryLegacyCompatibilityTest.kt new file mode 100644 index 000000000..3f62be5fe --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/CatalogRepositoryLegacyCompatibilityTest.kt @@ -0,0 +1,45 @@ +package com.arflix.tv.data.repository + +import android.content.Context +import com.arflix.tv.data.api.TraktApi +import com.arflix.tv.data.model.CatalogConfig +import com.arflix.tv.data.model.CatalogKind +import io.mockk.mockk +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Test + +class CatalogRepositoryLegacyCompatibilityTest { + @Test + fun `catalog without kind defaults to standard`() { + val repository = CatalogRepository( + context = mockk(relaxed = true), + profileManager = mockk(relaxed = true), + traktApi = mockk(relaxed = true), + okHttpClient = mockk(relaxed = true), + invalidationBus = mockk(relaxed = true) + ) + val legacyJson = """ + [ + { + "id": "legacy_catalog", + "title": "Legacy Catalog", + "sourceType": "PREINSTALLED" + } + ] + """.trimIndent() + + val parseMethod = CatalogRepository::class.java.getDeclaredMethod( + "parseCatalogsJson", + String::class.java + ).apply { + isAccessible = true + } + + @Suppress("UNCHECKED_CAST") + val parsed = parseMethod.invoke(repository, legacyJson) as List + + assertEquals(1, parsed.size) + assertEquals(CatalogKind.STANDARD, parsed.single().kind) + } +}