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/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..7c2190c1 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,9 +2166,28 @@ 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)}=["']([^"']*)["']""")
- 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 4c6da88b..64987c3c 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
@@ -978,7 +978,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()
@@ -988,8 +988,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)
@@ -1066,11 +1068,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]}"
}
@@ -6454,8 +6454,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(":")
@@ -7529,6 +7529,12 @@ class IptvRepository @Inject constructor(
// ════════════════════════════════════════════════════════════════════════
private companion object {
+ private val DURATION_SCALE_REGEX = Regex("""\$\{duration:(\d+)\}|\{duration:(\d+)\}""")
+ private val URL_QUERY_SECRETS_REGEX = Regex("""(?i)([?&](?:username|user|uname|password|pass|pwd)=)[^&]+""")
+ private val URL_PATH_SECRETS_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..ff7d9b73 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()
}
}
@@ -2957,10 +2971,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 ")
@@ -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()
}
}
@@ -3920,10 +4011,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 +4057,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/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/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt
index 703deed9..3cbe3cbb 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
@@ -211,19 +211,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"
@@ -2329,7 +2316,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)
}
@@ -2349,7 +2336,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()
@@ -2372,17 +2359,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)
@@ -2509,3 +2496,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 eaf4b6e4..a2f71de9 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
@@ -114,9 +114,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(
@@ -816,9 +813,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")
}
@@ -1494,6 +1491,7 @@ class HomeViewModel @Inject constructor(
try {
iptvRepository.warmXtreamVodCachesIfPossible()
} catch (e: Exception) {
+ AppLogger.e("HomeVM", "warmXtreamVodCachesIfPossible failed", e)
}
}
}
@@ -3084,6 +3082,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 +3526,7 @@ class HomeViewModel @Inject constructor(
}
} catch (e: Exception) {
// Logo fetch failed
+ AppLogger.e("HomeVM", "Hero logo fetch failed", e)
}
}
}
@@ -4226,7 +4226,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) {
@@ -4310,3 +4310,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/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/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt
index 2a2b369c..a40662d7 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
@@ -234,7 +234,6 @@ class SettingsViewModel @Inject constructor(
}
}
-
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -477,7 +476,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/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 37069e17..f358011d 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
@@ -1532,7 +1537,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
@@ -1631,3 +1636,7 @@ private fun IptvConfig.syncSignature(): String {
playlistsSignature
).joinToString("||")
}
+
+private object TvVMRegexes {
+ val NON_ALPHA_NUM = Regex("[^a-z0-9]+")
+}
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 4fcc7198..27e0f049 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,
@@ -1909,7 +1914,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]}"
}