From 0d1f92619facb5d5b84ff61020212c027721f161 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Sun, 24 May 2026 13:47:50 +0000 Subject: [PATCH 1/5] chore(refactor): extract inline Regex instances to constants for memory optimization --- .../data/repository/CollectionTemplateManifest.kt | 5 ++++- .../com/arflix/tv/ui/screens/player/PlayerScreen.kt | 13 +++++++++---- app/src/main/kotlin/com/arflix/tv/util/AppLogger.kt | 3 ++- .../kotlin/com/arflix/tv/util/ProfileAvatarFiles.kt | 6 +++++- .../kotlin/com/arflix/tv/util/SubtitleScoring.kt | 13 ++++++++----- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CollectionTemplateManifest.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CollectionTemplateManifest.kt index 3ba4e7cc..2814829d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CollectionTemplateManifest.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CollectionTemplateManifest.kt @@ -1087,8 +1087,11 @@ internal object CollectionTemplateManifest { .replace("+", "plus") .replace("&", "and") .replace("'", "") - .replace(Regex("[^a-z0-9]+"), "_") + .replace(CollectionManifestRegexes.SLUGIFY_NON_ALPHA_NUM_REGEX, "_") .trim('_') } } +private object CollectionManifestRegexes { + val SLUGIFY_NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 13425661..937e0e8d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -3614,7 +3614,7 @@ private fun SubtitleMenu( } else { badge = subtitle.provider.ifBlank { null } detail = subtitle.id - .replace(Regex("^\\[[^]]+]"), "").trim() + .replace(PlayerScreenRegexes.BRACKET_REGEX, "").trim() .ifBlank { subtitle.id } .ifBlank { null } } @@ -4351,11 +4351,10 @@ private fun parseSizeToBytes(sizeStr: String): Long { val normalized = sizeStr.uppercase() .replace(",", ".") - .replace(Regex("\\s+"), " ") + .replace(PlayerScreenRegexes.MULTI_SPACE_REGEX, " ") .trim() - val pattern = Regex("""(\d+(?:\.\d+)?)\s*(TB|GB|MB|KB)""") - val match = pattern.find(normalized) ?: return 0L + val match = PlayerScreenRegexes.SIZE_REGEX.find(normalized) ?: return 0L val number = match.groupValues[1].toDoubleOrNull() ?: return 0L val multiplier = when (match.groupValues[2]) { @@ -4624,3 +4623,9 @@ private fun subtitleMatchScore(streamSource: String, subtitle: Subtitle): Int { if (subtitle.isEmbedded) return 100 return weightedSubtitleScore(streamSource, subtitle.id) } + +private object PlayerScreenRegexes { + val BRACKET_REGEX = Regex("^\\[[^]]+]") + val MULTI_SPACE_REGEX = Regex("\\s+") + val SIZE_REGEX = Regex("""(\d+(?:\.\d+)?)\s*(TB|GB|MB|KB)""") +} diff --git a/app/src/main/kotlin/com/arflix/tv/util/AppLogger.kt b/app/src/main/kotlin/com/arflix/tv/util/AppLogger.kt index 77597587..983bc1e1 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/AppLogger.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/AppLogger.kt @@ -159,6 +159,7 @@ object AppLogger { private val IPV4_PATTERN = Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b") private val TOKEN_PATTERN = Regex("(token|jwt|bearer|api[_-]?key|secret)[\"':\\s=]+([a-zA-Z0-9._-]{20,})", RegexOption.IGNORE_CASE) private val LONG_HEX_PATTERN = Regex("[a-fA-F0-9]{32,}") + private val SAFE_TAG_PATTERN = Regex("[^A-Za-z0-9_.-]") /** * Sanitize a message by removing/masking PII. @@ -202,7 +203,7 @@ object AppLogger { private fun safeTag(tag: String): String { return tag - .replace(Regex("[^A-Za-z0-9_.-]"), "_") + .replace(SAFE_TAG_PATTERN, "_") .take(40) .ifBlank { "app" } } diff --git a/app/src/main/kotlin/com/arflix/tv/util/ProfileAvatarFiles.kt b/app/src/main/kotlin/com/arflix/tv/util/ProfileAvatarFiles.kt index 9e15458e..a45b9723 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/ProfileAvatarFiles.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/ProfileAvatarFiles.kt @@ -26,5 +26,9 @@ object ProfileAvatarFiles { } private fun safeName(value: String): String = - value.replace(Regex("[^A-Za-z0-9._-]"), "_") + value.replace(ProfileAvatarRegexes.SANITIZATION_REGEX, "_") +} + +private object ProfileAvatarRegexes { + val SANITIZATION_REGEX = Regex("[^A-Za-z0-9._-]") } diff --git a/app/src/main/kotlin/com/arflix/tv/util/SubtitleScoring.kt b/app/src/main/kotlin/com/arflix/tv/util/SubtitleScoring.kt index 3c23e7f2..bbc48fd6 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/SubtitleScoring.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/SubtitleScoring.kt @@ -49,8 +49,12 @@ private val FILE_EXT_RE = Regex("\\.(mkv|mp4|avi|mov|wmv|flv|m4v|ts|m2ts)$", Reg // Merge "S06 E01" written as separate tokens back into "S06E01" private val SPLIT_SEASON_RE = Regex("(s\\d{1,2})\\s+(e\\d{1,2})", RegexOption.IGNORE_CASE) +private val PURE_NUMBERS_RE = Regex("\\d+") +private val SUBTITLE_BRACKET_RE = Regex("^\\[[^]]+]") +private val SEPARATOR_RE = Regex("[.\\-_\\s]+") + private fun tokenWeight(token: String): Int = when { - token.matches(Regex("\\d+")) -> 0 // pure numbers: noise + token.matches(PURE_NUMBERS_RE) -> 0 // pure numbers: noise EPISODE_RE.matches(token) -> 8 // S01E01 token in RESOLUTIONS -> 3 token in SOURCES -> 4 @@ -77,7 +81,7 @@ private fun tokenWeight(token: String): Int = when { fun weightedSubtitleScore(streamSource: String, subtitleId: String): Int { if (streamSource.isBlank() || subtitleId.isBlank()) return 0 - val cleanId = subtitleId.replace(Regex("^\\[[^]]+]"), "").trim() + val cleanId = subtitleId.replace(SUBTITLE_BRACKET_RE, "").trim() // Normalise "S06 E01" → "S06E01" so space-separated season/episode merges with combined form fun normalise(s: String) = SPLIT_SEASON_RE.replace(s) { it.groupValues[1] + it.groupValues[2] } @@ -98,9 +102,8 @@ fun weightedSubtitleScore(streamSource: String, subtitleId: String): Int { val (streamBody, streamGroup) = bodyAndGroup(normalise(streamSource)) val (subBody, subGroup) = bodyAndGroup(normalise(cleanId)) - val sep = Regex("[.\\-_\\s]+") - val streamTokens = streamBody.lowercase().split(sep).filter { it.length > 1 } - val subTokenSet = subBody.lowercase().split(sep).filter { it.length > 1 }.toSet() + val streamTokens = streamBody.lowercase().split(SEPARATOR_RE).filter { it.length > 1 } + val subTokenSet = subBody.lowercase().split(SEPARATOR_RE).filter { it.length > 1 }.toSet() var totalWeight = 0 var matchedWeight = 0 From 39f23ecf8fec3cdcb68ac5f989b35660dfe31ba0 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Tue, 26 May 2026 13:52:13 +0000 Subject: [PATCH 2/5] chore(refactor): optimize memory footprint by extracting regex and formatting instances --- .../repository/CatalogDiscoveryRepository.kt | 10 +++-- .../tv/data/repository/CatalogRepository.kt | 33 ++++++++------- .../tv/data/repository/TraktRepository.kt | 18 ++++---- .../tv/ui/screens/details/DetailsViewModel.kt | 42 ++++++++++--------- .../tv/ui/screens/home/HomeViewModel.kt | 14 ++++--- .../ui/screens/settings/SettingsViewModel.kt | 3 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 6 ++- 7 files changed, 70 insertions(+), 56 deletions(-) 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 5746d09d..8d05d24a 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 @@ -187,7 +187,7 @@ class CatalogDiscoveryRepository @Inject constructor( append(result.description.orEmpty().lowercase()) } val tokens = query.lowercase() - .split(NON_ALPHA_NUM_REGEX) + .split(CatalogDiscoveryRepoRegexes.NON_ALPHA_NUM_REGEX) .filter { it.length >= 3 } .distinct() if (tokens.isEmpty()) return 0 @@ -197,7 +197,9 @@ class CatalogDiscoveryRepository @Inject constructor( titleScore + bodyScore } } - private companion object { - private val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") - } + +} + +private object CatalogDiscoveryRepoRegexes { + val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") } 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 250c8723..8e1beb32 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 @@ -973,9 +973,9 @@ class CatalogRepository @Inject constructor( } } - val titleFromMeta = TITLE_FROM_META_REGEX.find(html)?.groupValues?.getOrNull(1) + val titleFromMeta = CatalogRepoRegexes.TITLE_FROM_META_REGEX.find(html)?.groupValues?.getOrNull(1) - val titleFromTag = TITLE_FROM_TAG_REGEX.find(html)?.groupValues?.getOrNull(1) + val titleFromTag = CatalogRepoRegexes.TITLE_FROM_TAG_REGEX.find(html)?.groupValues?.getOrNull(1) ?.replace(" - MDBList", "", ignoreCase = true) val titleFromSlug = extractMdblistSlugTitle(url) @@ -999,7 +999,7 @@ class CatalogRepository @Inject constructor( } private fun extractTraktUrl(html: String): String? { - return TRAKT_URL_REGEX.find(html)?.value + return CatalogRepoRegexes.TRAKT_URL_REGEX.find(html)?.value } private suspend fun fetchUrl(url: String): String? { @@ -1291,18 +1291,6 @@ class CatalogRepository @Inject constructor( private companion object { private const val ADDON_SOURCE_REF_PREFIX = "addon_catalog|" - private val TITLE_FROM_META_REGEX = Regex( - """([^<]+)""", - RegexOption.IGNORE_CASE - ) - private val TRAKT_URL_REGEX = Regex( - """https?://(?:www\.)?trakt\.tv/users/[^"'\s<]+/lists/[^"'\s<]+""", - RegexOption.IGNORE_CASE - ) } } @@ -1315,3 +1303,18 @@ private fun String.toDisplayTitle(): String { } .ifBlank { "Custom Catalog" } } + +private object CatalogRepoRegexes { + val TITLE_FROM_META_REGEX = Regex( + """([^<]+)""", + RegexOption.IGNORE_CASE + ) + val TRAKT_URL_REGEX = Regex( + """https?://(?:www\.)?trakt\.tv/users/[^"'\s<]+/lists/[^"'\s<]+""", + RegexOption.IGNORE_CASE + ) +} 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 72662d33..edd5d9c2 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 @@ -2957,10 +2957,10 @@ class TraktRepository @Inject constructor( private fun normalizeWatchlistTitle(title: String): String { return Normalizer.normalize(title, Normalizer.Form.NFD) - .replace(DIACRITICS_REGEX, "") + .replace(TraktRepoRegexes.DIACRITICS_REGEX, "") .lowercase(Locale.US) .replace("&", "and") - .replace(NON_ALPHA_NUM_REGEX, " ") + .replace(TraktRepoRegexes.NON_ALPHA_NUM_REGEX, " ") .trim() .removePrefix("the ") .removePrefix("a ") @@ -3920,10 +3920,10 @@ private fun parseRuntimeLabelSeconds(label: String): Long { if (normalized.isBlank()) return 0L var minutes = 0L - HOURS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { hours -> + TraktRepoRegexes.HOURS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { hours -> minutes += hours * 60L } - MINS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { mins -> + TraktRepoRegexes.MINS_REGEX.find(normalized)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { mins -> minutes += mins } @@ -3966,7 +3966,9 @@ private fun buildEpisodeKey( } -private val DIACRITICS_REGEX = Regex("\\p{Mn}+") -private val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") -private val HOURS_REGEX = Regex("""(\d+)\s*h""") -private val MINS_REGEX = Regex("""(\d+)\s*m""") +private object TraktRepoRegexes { + val DIACRITICS_REGEX = Regex("\\p{Mn}+") + val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") + val HOURS_REGEX = Regex("""(\d+)\s*h""") + val MINS_REGEX = Regex("""(\d+)\s*m""") +} 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 1437092c..68504bee 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 @@ -209,19 +209,6 @@ class DetailsViewModel @Inject constructor( return value.isBlank() || value == "0.0" || value == "0" } - private val reviewWhitespaceRegex = Regex("\\s+") - private val reviewMarkdownLinkRegex = Regex("\\[([^\\]]+)]\\([^)]*\\)") - private val reviewHtmlTagRegex = Regex("<[^>]*>") - private val reviewMarkdownNoiseRegex = Regex("[*_`>#]+") - private val reviewSpamRegex = Regex( - pattern = "\\b(?:https?://|www\\.|discord\\.gg|t\\.me/|telegram|whatsapp|onlyfans|casino|betting|viagra|loan|crypto|airdrop|promo\\s+code|coupon|download\\s+now|watch\\s+(?:free|online)|free\\s+stream|\\.xyz\\b|\\.top\\b|\\.click\\b|\\.link\\b|\\.site\\b)\\b", - option = RegexOption.IGNORE_CASE - ) - private val reviewDomainRegex = Regex( - pattern = "\\b[a-z0-9-]+\\.(?:com|net|org|xyz|top|click|link|site|online|shop|info)\\b", - option = RegexOption.IGNORE_CASE - ) - private fun normalizeAutoPlayMinQuality(raw: String?): String { return when (raw?.trim()?.lowercase()) { "any" -> "Any" @@ -2307,7 +2294,7 @@ class DetailsViewModel @Inject constructor( if (cleanedContent.length !in MIN_COMMUNITY_REVIEW_CHARS..MAX_COMMUNITY_REVIEW_CHARS) { return@mapNotNull null } - val wordCount = cleanedContent.split(reviewWhitespaceRegex).count { it.length > 1 } + val wordCount = cleanedContent.split(DetailsVMRegexes.reviewWhitespaceRegex).count { it.length > 1 } if (wordCount < MIN_COMMUNITY_REVIEW_WORDS) return@mapNotNull null review.copy(content = cleanedContent) } @@ -2327,7 +2314,7 @@ class DetailsViewModel @Inject constructor( if (cleanedContent.length !in MIN_COMMUNITY_REVIEW_CHARS..MAX_COMMUNITY_REVIEW_CHARS) { return null } - val wordCount = cleanedContent.split(reviewWhitespaceRegex).count { it.length > 1 } + val wordCount = cleanedContent.split(DetailsVMRegexes.reviewWhitespaceRegex).count { it.length > 1 } if (wordCount < MIN_COMMUNITY_REVIEW_WORDS) return null val username = user?.username?.trim().orEmpty() @@ -2350,17 +2337,17 @@ class DetailsViewModel @Inject constructor( private fun cleanCommunityReviewText(raw: String): String { return raw - .replace(reviewMarkdownLinkRegex, "\$1") - .replace(reviewHtmlTagRegex, " ") - .replace(reviewMarkdownNoiseRegex, " ") - .replace(reviewWhitespaceRegex, " ") + .replace(DetailsVMRegexes.reviewMarkdownLinkRegex, "\$1") + .replace(DetailsVMRegexes.reviewHtmlTagRegex, " ") + .replace(DetailsVMRegexes.reviewMarkdownNoiseRegex, " ") + .replace(DetailsVMRegexes.reviewWhitespaceRegex, " ") .trim() } private fun isSpammyReviewText(raw: String): Boolean { val text = raw.trim() if (text.isBlank()) return true - if (reviewSpamRegex.containsMatchIn(text) || reviewDomainRegex.containsMatchIn(text)) return true + if (DetailsVMRegexes.reviewSpamRegex.containsMatchIn(text) || DetailsVMRegexes.reviewDomainRegex.containsMatchIn(text)) return true if (text.count { it == '$' } > 2 || text.count { it == '!' } > 6) return true val visibleChars = text.count { !it.isWhitespace() }.coerceAtLeast(1) @@ -2487,3 +2474,18 @@ class DetailsViewModel @Inject constructor( prewarmVisibleStreams(mergedStreams) } } + +private object DetailsVMRegexes { + val reviewWhitespaceRegex = Regex("\\s+") + val reviewMarkdownLinkRegex = Regex("\\[([^\\]]+)]\\([^)]*\\)") + val reviewHtmlTagRegex = Regex("<[^>]*>") + val reviewMarkdownNoiseRegex = Regex("[*_`>#]+") + val reviewSpamRegex = Regex( + pattern = "\\b(?:https?://|www\\.|discord\\.gg|t\\.me/|telegram|whatsapp|onlyfans|casino|betting|viagra|loan|crypto|airdrop|promo\\s+code|coupon|download\\s+now|watch\\s+(?:free|online)|free\\s+stream|\\.xyz\\b|\\.top\\b|\\.click\\b|\\.link\\b|\\.site\\b)\\b", + option = RegexOption.IGNORE_CASE + ) + val reviewDomainRegex = Regex( + pattern = "\\b[a-z0-9-]+\\.(?:com|net|org|xyz|top|click|link|site|online|shop|info)\\b", + option = RegexOption.IGNORE_CASE + ) +} 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 ae9a4ab4..7b98f14d 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 @@ -111,9 +111,6 @@ enum class ToastType { SUCCESS, ERROR, INFO } -private val ALPHANUMERIC_REGEX = Regex("[^A-Za-z0-9_.-]") -private val FILE_NAME_REGEX = Regex("[^a-zA-Z0-9._-]") - @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class HomeViewModel @Inject constructor( @@ -741,9 +738,9 @@ class HomeViewModel @Inject constructor( private fun categoriesCacheFile(): java.io.File { val profileId = profileManager.getProfileIdSync() .ifBlank { "default" } - .replace(ALPHANUMERIC_REGEX, "_") + .replace(HomeVMRegexes.ALPHANUMERIC_REGEX, "_") val language = (mediaRepository.contentLanguage ?: "en-US") - .replace(ALPHANUMERIC_REGEX, "_") + .replace(HomeVMRegexes.ALPHANUMERIC_REGEX, "_") return java.io.File(context.cacheDir, "home_categories_cache_${profileId}_$language.json") } @@ -4083,7 +4080,7 @@ class HomeViewModel @Inject constructor( downloadJob = viewModelScope.launch { updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(0f, update)) - val safeName = update.assetName.replace(FILE_NAME_REGEX, "_") + val safeName = update.assetName.replace(HomeVMRegexes.FILE_NAME_REGEX, "_") val dest = java.io.File(java.io.File(context.cacheDir, "updates"), safeName) val result = kotlinx.coroutines.withContext(Dispatchers.IO) { @@ -4167,3 +4164,8 @@ class HomeViewModel @Inject constructor( updateStatusManager.reset() } } + +private object HomeVMRegexes { + val ALPHANUMERIC_REGEX = Regex("[^A-Za-z0-9_.-]") + val FILE_NAME_REGEX = Regex("[^a-zA-Z0-9._-]") +} 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 af04c1ad..31eae9eb 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 @@ -229,7 +229,6 @@ class SettingsViewModel @Inject constructor( } } - private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -441,7 +440,7 @@ class SettingsViewModel @Inject constructor( } else { gson.fromJson>( json, - object : TypeToken>() {}.type + TypeToken.getParameterized(List::class.java, QualityFilterConfig::class.java).type ).orEmpty() } }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index ddcbc805..11081645 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -1017,7 +1017,7 @@ private fun buildPreparedChannelsByGroup( private fun String.isNetherlandsGroup(): Boolean { val tokens = lowercase() - .split(Regex("[^a-z0-9]+")) + .split(TvVMRegexes.NON_ALPHA_NUM) .filter { it.isNotBlank() } .toSet() return "netherlands" in tokens || "nederland" in tokens || "nl" in tokens @@ -1087,3 +1087,7 @@ private fun IptvConfig.syncSignature(): String { playlistsSignature ).joinToString("||") } + +private object TvVMRegexes { + val NON_ALPHA_NUM = Regex("[^a-z0-9]+") +} From 9379dfd84c0c1da8a960ce629910bf57bf3bfda9 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Sat, 30 May 2026 13:39:59 +0000 Subject: [PATCH 3/5] chore(refactor): optimize regex parsing and harden cloud sync error handling - Moved heavily used Regex constants out of functions into companion object in IptvRepository - Refactored xmlAttribute parsing to use optimized string matching instead of dynamic regex in HomeServerRepository - Modernized try-catch into runCatching in StreamRepository - Wrapped volatile Cloud Sync Auth checks in runCatching to avoid flow crashes --- .../data/repository/CloudSyncCoordinator.kt | 6 ++++-- .../data/repository/HomeServerRepository.kt | 21 +++++++++++++++++-- .../tv/data/repository/IptvRepository.kt | 11 +++++----- 3 files changed, 29 insertions(+), 9 deletions(-) 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 9c054c19..eb6c7cfe 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 @@ -30,7 +30,8 @@ class CloudSyncCoordinator @Inject constructor( if (!started.compareAndSet(false, true)) return collectorJob = scope.launch { invalidationBus.events.collectLatest { invalidation -> - if (authRepository.getCurrentUserId().isNullOrBlank()) return@collectLatest + val userId = runCatching { authRepository.getCurrentUserId() }.getOrNull() + if (userId.isNullOrBlank()) return@collectLatest cloudSyncRepository.markLocalStateDirtyNow() scheduleFlush(invalidation) } @@ -54,7 +55,8 @@ class CloudSyncCoordinator @Inject constructor( flushJob?.cancel() flushJob = scope.launch { delay(debounceMsFor(invalidation.scope)) - if (authRepository.getCurrentUserId().isNullOrBlank()) return@launch + val userId = runCatching { authRepository.getCurrentUserId() }.getOrNull() + if (userId.isNullOrBlank()) return@launch runCatching { cloudSyncRepository.pushToCloud() } .onFailure { error -> Log.w("CloudSyncCoordinator", "Cloud push failed after ${invalidation.scope}: ${error.message}") 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 5e317d85..c1c888a4 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 @@ -2167,8 +2167,25 @@ class HomeServerRepository @Inject constructor( runCatching { takeUnless { it.isJsonNull }?.asString }.getOrNull() private fun String.xmlAttribute(name: String): String { - val pattern = Regex("""\b${Regex.escape(name)}=["']([^"']*)["']""") - return pattern.find(this)?.groupValues?.getOrNull(1).orEmpty().xmlDecoded() + val target = "$name=" + var index = this.indexOf(target) + // Ensure word boundary check to avoid substring matches + while (index != -1) { + if (index == 0 || !(this[index - 1].isLetterOrDigit() || this[index - 1] == '_')) { + val valueStart = index + target.length + if (valueStart < this.length) { + val quote = this[valueStart] + if (quote == '"' || quote == '\'') { + val valueEnd = this.indexOf(quote, valueStart + 1) + if (valueEnd != -1) { + return this.substring(valueStart + 1, valueEnd).xmlDecoded() + } + } + } + } + index = this.indexOf(target, index + target.length) + } + return "" } private fun String.xmlBooleanAttribute(name: String): Boolean { 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 32890657..2f395b51 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 @@ -949,7 +949,7 @@ class IptvRepository @Inject constructor( } private fun String.replaceDurationScalePlaceholders(durationSec: Long): String { - return Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""").replace(this) { match -> + return DURATION_SCALE_REGEX.replace(this) { match -> val divisor = (match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2)) ?.toLongOrNull() @@ -1037,11 +1037,9 @@ class IptvRepository @Inject constructor( } private fun redactIptvUrl(url: String): String { - val withoutQuerySecrets = Regex( - pattern = """(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""" - ).replace(url) { match -> "${match.groupValues[1]}***" } + val withoutQuerySecrets = URL_QUERY_SECRETS_REGEX.replace(url) { match -> "${match.groupValues[1]}***" } - return Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + return URL_PATH_SECRETS_REGEX .replace(withoutQuerySecrets) { match -> "${match.groupValues[1]}***/***${match.groupValues[4]}" } @@ -7143,6 +7141,9 @@ class IptvRepository @Inject constructor( private companion object { const val ENC_PREFIX = "encv1:" const val ANDROID_KEYSTORE = "AndroidKeyStore" + val DURATION_SCALE_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""") + val URL_QUERY_SECRETS_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""") + val URL_PATH_SECRETS_REGEX = Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") const val CONFIG_KEY_ALIAS = "arvio_iptv_config_v1" const val MAX_IPTV_CACHE_BYTES = 25L * 1024L * 1024L const val IPTV_USER_AGENT = "VLC/3.0.20 LibVLC/3.0.20" From 61f642df9b7c10c893a64fdb7240e346a34de6fd Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:05:46 +0000 Subject: [PATCH 4/5] refactor(core): optimize stream parsing layers and harden error handling --- .../data/repository/HomeServerRepository.kt | 4 +- .../tv/data/repository/IptvRepository.kt | 22 +++-- .../tv/data/repository/TraktRepository.kt | 91 +++++++++++++++++++ 3 files changed, 108 insertions(+), 9 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 5e317d85..61900fe5 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 @@ -2166,8 +2166,10 @@ class HomeServerRepository @Inject constructor( private fun JsonElement.asStringOrNull(): String? = runCatching { takeUnless { it.isJsonNull }?.asString }.getOrNull() + private val xmlAttributeRegexCache = java.util.concurrent.ConcurrentHashMap() + private fun String.xmlAttribute(name: String): String { - val pattern = Regex("""\b${Regex.escape(name)}=["']([^"']*)["']""") + val pattern = xmlAttributeRegexCache.getOrPut(name) { Regex("""\b${Regex.escape(name)}=["']([^"']*)["']""") } return pattern.find(this)?.groupValues?.getOrNull(1).orEmpty().xmlDecoded() } 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 32890657..d9528fc9 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 @@ -949,7 +949,7 @@ class IptvRepository @Inject constructor( } private fun String.replaceDurationScalePlaceholders(durationSec: Long): String { - return Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""").replace(this) { match -> + return DURATION_SCALE_REGEX.replace(this) { match -> val divisor = (match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2)) ?.toLongOrNull() @@ -959,8 +959,10 @@ class IptvRepository @Inject constructor( } } + private val datePatternRegexCache = java.util.concurrent.ConcurrentHashMap() + private fun String.replaceDatePatternPlaceholders(key: String, dateTime: LocalDateTime): String { - val regex = Regex("""\$\{""" + key + """:([^}]+)\}|\{""" + key + """:([^}]+)\}""") + val regex = datePatternRegexCache.getOrPut(key) { Regex("""\$\{""" + key + """:([^}]+)\}|\{""" + key + """:([^}]+)\}""") } return regex.replace(this) { match -> val pattern = match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2) @@ -1037,11 +1039,9 @@ class IptvRepository @Inject constructor( } private fun redactIptvUrl(url: String): String { - val withoutQuerySecrets = Regex( - pattern = """(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""" - ).replace(url) { match -> "${match.groupValues[1]}***" } + val withoutQuerySecrets = QUERY_SECRETS_REGEX.replace(url) { match -> "${match.groupValues[1]}***" } - return Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + return IPTV_CREDENTIALS_URL_REGEX .replace(withoutQuerySecrets) { match -> "${match.groupValues[1]}***/***${match.groupValues[4]}" } @@ -6174,8 +6174,8 @@ class IptvRepository @Inject constructor( val base = epgId?.takeIf { it.isNotBlank() } ?: name val normalizedBase = normalizeLooseKey( base - .replace(Regex("""\b(4K|UHD|FHD|HD|SD|2160P?|1080P?|720P?|576P?|480P?)\b""", RegexOption.IGNORE_CASE), " ") - .replace(Regex("""\[[^\]]*]|\([^)]*\)"""), " ") + .replace(QUALITY_WORDS_REGEX, " ") + .replace(BRACKET_PAREN_REGEX, " ") ) val normalizedGroup = normalizeLooseKey(group) return listOf(normalizedGroup, normalizedBase).filter { it.isNotBlank() }.joinToString(":") @@ -7141,6 +7141,12 @@ class IptvRepository @Inject constructor( // ════════════════════════════════════════════════════════════════════════ private companion object { + private val DURATION_SCALE_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""") + private val QUERY_SECRETS_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""") + private val IPTV_CREDENTIALS_URL_REGEX = Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + private val QUALITY_WORDS_REGEX = Regex("""\b(4K|UHD|FHD|HD|SD|2160P?|1080P?|720P?|576P?|480P?)\b""", RegexOption.IGNORE_CASE) + private val BRACKET_PAREN_REGEX = Regex("""\[[^\]]*]|\([^)]*\)""") + const val ENC_PREFIX = "encv1:" const val ANDROID_KEYSTORE = "AndroidKeyStore" const val CONFIG_KEY_ALIAS = "arvio_iptv_config_v1" 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 72662d33..859a15ed 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 @@ -566,7 +566,14 @@ class TraktRepository @Inject constructor( return try { val watched = traktApi.getWatchedMovies(auth, clientId) watched.mapNotNull { it.movie.ids.tmdb }.toSet() + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptySet() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptySet() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptySet() } } @@ -591,7 +598,14 @@ class TraktRepository @Inject constructor( } } episodes + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptySet() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptySet() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptySet() } } @@ -3071,7 +3085,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getCollectionMovies(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3083,7 +3104,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getCollectionShows(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3177,7 +3205,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getRatingsMovies(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3189,7 +3224,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getRatingsShows(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3201,7 +3243,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getRatingsEpisodes(auth, clientId) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3313,7 +3362,14 @@ class TraktRepository @Inject constructor( suspend fun getMovieComments(mediaId: String, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getMovieComments(clientId, "2", mediaId, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3328,7 +3384,14 @@ class TraktRepository @Inject constructor( suspend fun getShowComments(mediaId: String, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getShowComments(clientId, "2", mediaId, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3343,7 +3406,14 @@ class TraktRepository @Inject constructor( suspend fun getSeasonComments(showId: String, season: Int, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getSeasonComments(clientId, "2", showId, season, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3358,7 +3428,14 @@ class TraktRepository @Inject constructor( suspend fun getEpisodeComments(showId: String, season: Int, episode: Int, page: Int = 1, limit: Int = 10, sort: String = "newest"): List { return try { traktApi.getEpisodeComments(clientId, "2", showId, season, episode, sort, page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3516,7 +3593,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getHistoryMovies(auth, clientId, "2", page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } @@ -3528,7 +3612,14 @@ class TraktRepository @Inject constructor( val auth = getAuthHeader() ?: return emptyList() return try { traktApi.getHistoryEpisodes(auth, clientId, "2", page, limit) + } catch (e: java.io.IOException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Network or IO error, returning default", e) + emptyList() + } catch (e: retrofit2.HttpException) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "HTTP error fetching data, returning default", e) + emptyList() } catch (e: Exception) { + com.arflix.tv.util.AppLogger.e("TraktRepository", "Unknown error fetching data, returning default", e) emptyList() } } From 758933babb22422f9615491bb21e9b2e2647a0b5 Mon Sep 17 00:00:00 2001 From: Himanth-reddy <176995830+Himanth-reddy@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:55:39 +0000 Subject: [PATCH 5/5] chore(refactor): optimize regex parsing and harden error handling --- .../tv/data/repository/IptvRepository.kt | 18 +++++++++++------- .../tv/ui/screens/details/DetailsScreen.kt | 10 ++++++++-- .../arflix/tv/ui/screens/home/HomeViewModel.kt | 3 +++ .../player/AiSubtitleRenderersFactory.kt | 14 ++++++++++---- .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 7 ++++++- .../com/arflix/tv/ui/screens/tv/TvViewModel.kt | 7 ++++++- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 7 ++++++- 7 files changed, 50 insertions(+), 16 deletions(-) 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 32890657..1c187470 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 @@ -949,7 +949,7 @@ class IptvRepository @Inject constructor( } private fun String.replaceDurationScalePlaceholders(durationSec: Long): String { - return Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""").replace(this) { match -> + return DURATION_PLACEHOLDER_REGEX.replace(this) { match -> val divisor = (match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() } ?: match.groupValues.getOrNull(2)) ?.toLongOrNull() @@ -1037,11 +1037,9 @@ class IptvRepository @Inject constructor( } private fun redactIptvUrl(url: String): String { - val withoutQuerySecrets = Regex( - pattern = """(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""" - ).replace(url) { match -> "${match.groupValues[1]}***" } + val withoutQuerySecrets = IPTV_QUERY_SECRET_REGEX.replace(url) { match -> "${match.groupValues[1]}***" } - return Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + return IPTV_URL_REDACT_REGEX .replace(withoutQuerySecrets) { match -> "${match.groupValues[1]}***/***${match.groupValues[4]}" } @@ -6174,8 +6172,8 @@ class IptvRepository @Inject constructor( val base = epgId?.takeIf { it.isNotBlank() } ?: name val normalizedBase = normalizeLooseKey( base - .replace(Regex("""\b(4K|UHD|FHD|HD|SD|2160P?|1080P?|720P?|576P?|480P?)\b""", RegexOption.IGNORE_CASE), " ") - .replace(Regex("""\[[^\]]*]|\([^)]*\)"""), " ") + .replace(QUALITY_CLEANUP_REGEX, " ") + .replace(BRACKET_CLEANUP_REGEX, " ") ) val normalizedGroup = normalizeLooseKey(group) return listOf(normalizedGroup, normalizedBase).filter { it.isNotBlank() }.joinToString(":") @@ -7141,6 +7139,12 @@ class IptvRepository @Inject constructor( // ════════════════════════════════════════════════════════════════════════ private companion object { + val DURATION_PLACEHOLDER_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""") + val IPTV_URL_REDACT_REGEX = Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + val IPTV_QUERY_SECRET_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""") + val QUALITY_CLEANUP_REGEX = Regex("""\b(4K|UHD|FHD|HD|SD|2160P?|1080P?|720P?|576P?|480P?)\b""", RegexOption.IGNORE_CASE) + val BRACKET_CLEANUP_REGEX = Regex("""\[[^\]]*]|\([^)]*\)""") + const val ENC_PREFIX = "encv1:" const val ANDROID_KEYSTORE = "AndroidKeyStore" const val CONFIG_KEY_ALIAS = "arvio_iptv_config_v1" diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index e7b77b8c..c7a72685 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -179,6 +179,12 @@ import java.util.Locale import kotlin.math.abs import androidx.compose.ui.res.stringResource + +private object DetailsScreenRegexes { + val FOUR_K_REGEX = Regex("""\b4[kK]\b""") + val YEAR_REGEX = Regex("""\d{4}""") +} + /** * Details screen for movies and TV shows */ @@ -1020,7 +1026,7 @@ private fun qualityScoreForAutoPlay(quality: String): Int { private fun qualityScoreForStream(stream: com.arflix.tv.data.model.StreamSource): Int { val combined = listOfNotNull(stream.quality, stream.source, stream.addonName).joinToString(" ") return when { - combined.contains("2160p", ignoreCase = true) || Regex("\\b4[kK]\\b").containsMatchIn(combined) -> 4 + combined.contains("2160p", ignoreCase = true) || DetailsScreenRegexes.FOUR_K_REGEX.containsMatchIn(combined) -> 4 combined.contains("1080p", ignoreCase = true) -> 3 combined.contains("720p", ignoreCase = true) -> 2 combined.contains("480p", ignoreCase = true) -> 1 @@ -1200,7 +1206,7 @@ private fun DetailsContent( } val displayDate = item.year.takeIf { it.isNotBlank() } ?: item.releaseDate?.trim()?.takeIf { it.isNotEmpty() }?.let { date -> - Regex("\\d{4}").find(date)?.value ?: date + DetailsScreenRegexes.YEAR_REGEX.find(date)?.value ?: date } ?: "" val hasDuration = item.duration.isNotEmpty() && item.duration != "0m" 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 eaf4b6e4..b519d49d 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 @@ -1494,6 +1494,7 @@ class HomeViewModel @Inject constructor( try { iptvRepository.warmXtreamVodCachesIfPossible() } catch (e: Exception) { + AppLogger.e("HomeVM", "warmXtreamVodCachesIfPossible failed", e) } } } @@ -3084,6 +3085,7 @@ class HomeViewModel @Inject constructor( } } catch (e: Exception) { // Silently fail - don't clear existing data on error + AppLogger.e("HomeVM", "launchContinueWatchingFetch failed", e) } } } @@ -3527,6 +3529,7 @@ class HomeViewModel @Inject constructor( } } catch (e: Exception) { // Logo fetch failed + AppLogger.e("HomeVM", "Hero logo fetch failed", e) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt index 4c15dbda..c920c558 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/AiSubtitleRenderersFactory.kt @@ -11,6 +11,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch + +private object AiSubtitleRegexes { + val BRACKET_REGEX = Regex("""\[.*?\]""") + val MUSIC_REGEX = Regex("[♪♫]+") +} + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class AiSubtitleRenderersFactory( context: Context, @@ -145,8 +151,8 @@ private class TranslatingTextOutput( } private fun stripHearingImpaired(text: String): String = - text.replace(Regex("\\[.*?]"), "") - .replace(Regex("[♪♫]+"), "") + text.replace(AiSubtitleRegexes.BRACKET_REGEX, "") + .replace(AiSubtitleRegexes.MUSIC_REGEX, "") .trim() private fun buildTranslated(group: CueGroup, originalCues: List, translatedText: String): CueGroup = @@ -385,8 +391,8 @@ private class SubtitleOffsetRenderer( .let { if (removeHI) stripHI(it) else it } private fun stripHI(text: String): String = - text.replace(Regex("\\[.*?]"), "") - .replace(Regex("[♪♫]+"), "") + text.replace(AiSubtitleRegexes.BRACKET_REGEX, "") + .replace(AiSubtitleRegexes.MUSIC_REGEX, "") .trim() private fun findField(startClass: Class<*>, name: String): java.lang.reflect.Field? { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 54b53b44..92863e4f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -138,6 +138,11 @@ import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay import kotlin.math.abs + +private object TvScreenRegexes { + val NON_ALPHANUMERIC_REGEX = Regex("""[^a-z0-9]+""") +} + private enum class TvFocusZone { SIDEBAR, GROUPS, @@ -169,7 +174,7 @@ private fun preferredStartupGroup( private fun String.isPriorityGuideGroup(): Boolean { if (this == FAVORITES_GROUP_NAME) return true val tokens = lowercase() - .split(Regex("[^a-z0-9]+")) + .split(TvScreenRegexes.NON_ALPHANUMERIC_REGEX) .filter { it.isNotBlank() } .toSet() return "netherlands" in tokens || "nederland" in tokens || "nl" in tokens diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index e654185f..28407917 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -24,6 +24,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject + +private object TvViewModelRegexes { + val NON_ALPHANUMERIC_REGEX = Regex("""[^a-z0-9]+""") +} + internal const val FAVORITES_GROUP_NAME = "My Favorites" private const val EpgLoadingStateLimit = 800 private const val EpgAttemptedStateLimit = 2_400 @@ -1343,7 +1348,7 @@ private fun buildPreparedChannelsByGroup( private fun String.isNetherlandsGroup(): Boolean { val tokens = lowercase() - .split(Regex("[^a-z0-9]+")) + .split(TvViewModelRegexes.NON_ALPHANUMERIC_REGEX) .filter { it.isNotBlank() } .toSet() return "netherlands" in tokens || "nederland" in tokens || "nl" in tokens diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index c87ec85b..4471e503 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -98,6 +98,11 @@ import okhttp3.ConnectionPool import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit + +private object LiveTvScreenRegexes { + val IPTV_URL_REDACT_REGEX = Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") +} + private enum class LiveTvFocusZone { TOPBAR, PROVIDER_SWITCHER, @@ -1621,7 +1626,7 @@ private fun redactPlaybackUrl(url: String): String { pattern = """(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""" ).replace(url) { match -> "${match.groupValues[1]}***" } - return Regex("""(?i)(/(?:live|movie|series|timeshift)/)([^/]+)/([^/]+)(/)""") + return LiveTvScreenRegexes.IPTV_URL_REDACT_REGEX .replace(withoutQuerySecrets) { match -> "${match.groupValues[1]}***/***${match.groupValues[4]}" }