Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,30 @@ 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) {
authRepository.authState.first { it !is AuthState.Loading }
}

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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<CollectionSourceConfig>>(
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<List<String>>(
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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -76,20 +94,31 @@ 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)
Log.w("CloudSyncCoordinator", "Deferred cloud sync for ${invalidation.scope}: auth not ready")
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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -1219,7 +1227,7 @@ class HomeServerRepository @Inject constructor(
}

private fun parsePlexResourcesXml(xml: String): List<PlexResourceDevice> {
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() }
Expand All @@ -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(
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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<String, Regex>()

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()
}

Expand Down Expand Up @@ -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(
"""<Device\b([^>]*)>(.*?)</Device>|<Device\b([^>]*)/>""",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
private val PLEX_CONNECTION_REGEX = Regex("""<Connection\b([^>]*)/?\s*>""", RegexOption.IGNORE_CASE)



Expand All @@ -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(
"""<Device\b([^>]*)>(.*?)</Device>|<Device\b([^>]*)/>""",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
val PLEX_CONNECTION_REGEX = Regex("""<Connection\b([^>]*)/?\s*>""", RegexOption.IGNORE_CASE)
}
Loading
Loading