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
6 changes: 6 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.arflix.tv.data.model

import androidx.compose.runtime.Immutable
import java.time.Instant

/**
Expand All @@ -12,6 +13,7 @@ import java.time.Instant
* @property licenseData Optional PSSH override (base64). Most MPD manifests declare
* PSSH inline or in the init segment; ExoPlayer handles both automatically.
*/
@Immutable
data class DrmInfo(
val scheme: String,
val licenseUrl: String? = null,
Expand All @@ -21,6 +23,7 @@ data class DrmInfo(
/**
* IPTV channel parsed from an M3U playlist.
*/
@Immutable
data class IptvChannel(
val id: String,
val name: String,
Expand All @@ -46,6 +49,7 @@ data class IptvChannel(
/**
* Compact now/next program slice for a channel.
*/
@Immutable
data class IptvNowNext(
val now: IptvProgram? = null,
val next: IptvProgram? = null,
Expand All @@ -57,6 +61,7 @@ data class IptvNowNext(
/**
* EPG program row.
*/
@Immutable
data class IptvProgram(
val title: String,
val description: String? = null,
Expand All @@ -71,6 +76,7 @@ data class IptvProgram(
/**
* Loaded IPTV snapshot used by UI.
*/
@Immutable
data class IptvSnapshot(
val channels: List<IptvChannel> = emptyList(),
val grouped: Map<String, List<IptvChannel>> = emptyMap(),
Expand Down
26 changes: 16 additions & 10 deletions app/src/main/kotlin/com/arflix/tv/data/repository/AuthRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ private data class AccountSyncPayloadCandidate(
private class AccountSyncPayloadRejectedException(message: String) : Exception(message)

private fun parseJsonObject(payload: String): com.google.gson.JsonObject? {
return runCatching { JsonParser().parse(payload).asJsonObject }.getOrNull()
return try { JsonParser().parse(payload).asJsonObject } catch (e: com.google.gson.JsonSyntaxException) { null } catch (e: IllegalStateException) { null }
}

internal fun accountSyncPayloadProfileCount(payload: String): Int? {
Expand Down Expand Up @@ -225,7 +225,7 @@ private fun accountSyncPayloadsMatch(expected: String, actual: String?): Boolean

private fun safePostgrestError(body: String): String {
if (body.isBlank()) return "empty response"
val parsed = runCatching { JSONObject(body) }.getOrNull()
val parsed = try { JSONObject(body) } catch (e: org.json.JSONException) { null }
return parsed?.optString("message")?.takeIf { it.isNotBlank() }
?: parsed?.optString("error")?.takeIf { it.isNotBlank() }
?: body.take(180)
Expand Down Expand Up @@ -679,7 +679,7 @@ class AuthRepository @Inject constructor(

okHttpClient.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
val json = runCatching { JSONObject(body) }.getOrNull()
val json = try { JSONObject(body) } catch (e: org.json.JSONException) { null }
if (!response.isSuccessful) {
val message = cloudAuthErrorMessage(json, defaultError)
throw IllegalStateException(message)
Expand Down Expand Up @@ -1708,17 +1708,17 @@ class AuthRepository @Inject constructor(

private suspend fun saveAccountSyncPayloadToNetlify(payload: String): Result<Unit> {
return try {
val payloadValue = runCatching { JSONObject(payload) }.getOrNull() ?: payload
val payloadValue = try { JSONObject(payload) } catch (e: org.json.JSONException) { null } ?: payload
val body = JSONObject()
.put("payload", payloadValue)
.toString()
val responseBody = callNetlifyFunction(
url = Constants.NETLIFY_ACCOUNT_SYNC_PUSH_URL,
body = body
)
val responseJson = runCatching { JSONObject(responseBody) }.getOrNull()
if (responseJson?.optBoolean("accepted", true) == false) {
val reason = responseJson.optString("reason", "existing_snapshot_is_richer")
val responseJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null }
if (responseJson == null || !responseJson.optBoolean("accepted", false)) {
val reason = responseJson?.optString("reason", "invalid_response") ?: "invalid_response"
throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason")
}
Result.success(Unit)
Expand Down Expand Up @@ -1752,7 +1752,7 @@ class AuthRepository @Inject constructor(
"Cloud sync upload failed (${response.code}): ${safePostgrestError(responseBody)}"
)
}
val rpcJson = runCatching { JSONObject(responseBody) }.getOrNull()
val rpcJson = try { JSONObject(responseBody) } catch (e: org.json.JSONException) { null }
if (rpcJson?.optBoolean("accepted", true) == false) {
val reason = rpcJson.optString("reason", "existing_snapshot_is_richer")
throw AccountSyncPayloadRejectedException("Cloud sync upload rejected: $reason")
Expand Down Expand Up @@ -2110,7 +2110,13 @@ class AuthRepository @Inject constructor(

private fun parseInstantMillis(value: String?): Long {
if (value.isNullOrBlank()) return 0L
return runCatching { Instant.parse(value).toEpochMilliseconds() }.getOrDefault(0L)
return try {
Instant.parse(value).toEpochMilliseconds()
} catch (e: IllegalArgumentException) {
0L
} catch (e: java.time.format.DateTimeParseException) {
0L
}
}

private fun encodeProfileAccountSyncPayload(existingAddons: String?, payload: String): String {
Expand Down Expand Up @@ -2139,7 +2145,7 @@ class AuthRepository @Inject constructor(
val root = if (existingPayload.isBlank()) {
JSONObject()
} else {
runCatching { JSONObject(existingPayload) }.getOrElse { JSONObject() }
try { JSONObject(existingPayload) } catch (e: org.json.JSONException) { JSONObject() }
}

root.put("version", root.optInt("version", 1))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,11 +551,11 @@ class CloudSyncRepository @Inject constructor(
* other=local (never let an older remote value overwrite a newer-unpushed local one).
*/
private fun mergeSettingsByTimestamp(baseStr: String, otherStr: String): SettingsMergeResult {
val base = runCatching { JSONObject(baseStr) }.getOrNull() ?: return SettingsMergeResult(baseStr, emptySet())
val other = runCatching { JSONObject(otherStr) }.getOrNull() ?: return SettingsMergeResult(baseStr, emptySet())
val base = try { JSONObject(baseStr) } catch (e: org.json.JSONException) { null } ?: return SettingsMergeResult(baseStr, emptySet())
val other = try { JSONObject(otherStr) } catch (e: org.json.JSONException) { null } ?: return SettingsMergeResult(baseStr, emptySet())
val baseTs = base.optJSONObject("fieldUpdatedAt") ?: JSONObject()
val otherTs = other.optJSONObject("fieldUpdatedAt") ?: JSONObject()
val mergedTs = runCatching { JSONObject(baseTs.toString()) }.getOrDefault(JSONObject())
val mergedTs = try { JSONObject(baseTs.toString()) } catch (e: org.json.JSONException) { JSONObject() }
val otherWon = HashSet<String>()
val allKeys = LinkedHashSet<String>().apply { addAll(mergeKeysOf(base)); addAll(mergeKeysOf(other)) }
for (key in allKeys) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ class HomeServerRepository @Inject constructor(

private fun parseConnections(json: String?): List<HomeServerConnection> {
if (json.isNullOrBlank()) return emptyList()
return runCatching {
return try {
val root = JsonParser().parse(json)
val connections = when {
root.isJsonObject && root.asJsonObject.has("connections") -> {
Expand All @@ -719,7 +719,11 @@ class HomeServerRepository @Inject constructor(
.map { it.sanitized().decryptedForUse() }
.filter { it.serverUrl.isNotBlank() || it.accessToken.isNotBlank() }
.distinctBy { connectionIdentity(it) }
}.getOrDefault(emptyList())
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
com.arflix.tv.util.AppLogger.recordException(e)
emptyList()
}
}

private fun HomeServerConnection.encryptedForStorage(): HomeServerConnection {
Expand Down Expand Up @@ -2529,7 +2533,7 @@ private object HomeServerXmlRegexCache {
}
}
}
private object HomeServerRegexes {
internal object HomeServerRegexes {
val DIACRITICS_REGEX = Regex("\\p{Mn}+")
val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+")
val ARTICLES_REGEX = Regex("\\b(the|a|an)\\b")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,26 +344,37 @@ internal class IptvChannelStore(context: Context) : SQLiteOpenHelper(

fun deleteSource(sourceKey: String) {
if (sourceKey.isBlank()) return
writableDatabase.runCatching {
beginTransaction()
try {
writableDatabase.beginTransaction()
try {
delete("channels", "source_key = ?", arrayOf(sourceKey))
delete("channel_sources", "source_key = ?", arrayOf(sourceKey))
setTransactionSuccessful()
writableDatabase.delete("channels", "source_key = ?", arrayOf(sourceKey))
writableDatabase.delete("channel_sources", "source_key = ?", arrayOf(sourceKey))
writableDatabase.setTransactionSuccessful()
} finally {
endTransaction()
writableDatabase.endTransaction()
}
} catch (e: Exception) {
// Ignore DB errors on delete
}
}

private fun readChannel(cursor: android.database.Cursor, c: ColumnIndices): IptvChannel {
val headersJson = if (cursor.isNull(c.requestHeaders)) null else cursor.getString(c.requestHeaders)
val drmJson = if (cursor.isNull(c.drm)) null else cursor.getString(c.drm)
@Suppress("UNCHECKED_CAST")
val headers = headersJson?.let {
runCatching { gson.fromJson(it, Map::class.java) as? Map<String, String> }.getOrNull()
try {
val jsonElement = com.google.gson.JsonParser.parseString(it)
if (jsonElement.isJsonObject) {
jsonElement.asJsonObject.entrySet().mapNotNull { entry ->
val value = entry.value
if (value != null && value.isJsonPrimitive && value.asJsonPrimitive.isString) {
entry.key to value.asString
} else null
}.toMap()
} else null
} catch (e: Exception) { null }
}.orEmpty()
val drm = drmJson?.let { runCatching { gson.fromJson(it, DrmInfo::class.java) }.getOrNull() }
val drm = drmJson?.let { try { gson.fromJson(it, DrmInfo::class.java) } catch (e: Exception) { null } }
val name = cursor.getString(c.name).orEmpty()
return IptvChannel(
id = cursor.getString(c.id).orEmpty(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ internal class IptvPlaybackUrlResolver(
headers: Map<String, String>,
useHead: Boolean,
): ProbeResult? {
return runCatching {
return try {
val request = Request.Builder()
.url(url)
.apply {
Expand Down Expand Up @@ -117,7 +117,11 @@ internal class IptvPlaybackUrlResolver(
contentType.isDirectMediaContentType(),
)
}
}.getOrNull()
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
}

Expand All @@ -130,7 +134,7 @@ internal fun shouldResolveIptvPlaybackRedirect(url: String): Boolean {
}
if (looksLikeHlsPlaybackUrl(trimmed)) return false

val uri = runCatching { URI(trimmed) }.getOrNull() ?: return false
val uri = try { URI(trimmed) } catch (e: Exception) { null } ?: return false
val path = uri.path.orEmpty().trimEnd('/').lowercase(Locale.US)
val lastSegment = path.substringAfterLast('/')
if (lastSegment.isBlank() || lastSegment.contains('.')) return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1242,9 +1242,12 @@ class IptvRepository @Inject constructor(
if (pattern in setOf("Y", "m", "d", "H", "M", "S")) {
return@replace match.value
}
try { Result.success(dateTime.format(IptvRepoDateRegexes.formatterFor(pattern))) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e
Result.failure<String>(e) }
.getOrDefault(match.value)
try {
dateTime.format(IptvRepoDateRegexes.formatterFor(pattern))
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
match.value
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2041,7 +2041,7 @@ class MediaRepository @Inject constructor(
val body = withContext(Dispatchers.IO) {
fetchUrl("https://mdblist.com/lists/$slug/json")
} ?: return emptyList()
val array = runCatching { JSONArray(body) }.getOrNull() ?: return emptyList()
val array = try { org.json.JSONArray(body) } catch (e: org.json.JSONException) { null } ?: return emptyList()
val refs = mutableListOf<Pair<MediaType, Int>>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,19 @@ class ProfileAvatarImageManager @Inject constructor(
?: loadInlineAvatarFromCloud(profile.id)

if (!resolvedInlineBase64.isNullOrBlank()) {
runCatching {
try {
val bytes = Base64.decode(resolvedInlineBase64, Base64.NO_WRAP)
file.writeBytes(bytes)
ProfileAvatarFiles.cleanupProfile(context, profile.id, keepVersion = profile.avatarImageVersion)
}.onSuccess { return@withContext }
return@withContext
} catch (e: IllegalArgumentException) {
com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Base64 decode error: ${e.message}")
} catch (e: java.io.IOException) {
com.arflix.tv.util.AppLogger.e("ProfileAvatar", "IO error writing avatar: ${e.message}")
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Unexpected error restoring avatar: ${e.message}")
}
}

val storagePath = profile.avatarImageStoragePath?.trim().orEmpty()
Expand Down Expand Up @@ -152,7 +160,7 @@ class ProfileAvatarImageManager @Inject constructor(

private suspend fun uploadAvatar(profileId: String, version: Long, file: File): Result<String> =
withContext(Dispatchers.IO) {
runCatching {
try {
if (Constants.USE_NETLIFY_CLOUD_SYNC) {
error("Remote avatar storage is handled by account sync")
}
Expand All @@ -173,13 +181,19 @@ class ProfileAvatarImageManager @Inject constructor(
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) error(context.getString(R.string.avatar_upload_failed, response.code))
}
path
Result.success(path)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: java.io.IOException) {
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}

private suspend fun downloadAvatar(storagePath: String, destination: File): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
try {
if (Constants.USE_NETLIFY_CLOUD_SYNC) {
error("Remote avatar storage is handled by account sync")
}
Expand All @@ -196,19 +210,33 @@ class ProfileAvatarImageManager @Inject constructor(
val bytes = response.body?.bytes() ?: error(context.getString(R.string.avatar_response_empty))
destination.writeBytes(bytes)
}
Result.success(Unit)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: java.io.IOException) {
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}

private suspend fun loadInlineAvatarFromCloud(profileId: String): String? {
return authRepository.loadAccountSyncPayload().getOrNull()
?.takeIf { it.isNotBlank() }
?.let { payload ->
runCatching {
try {
JSONObject(payload)
.optJSONObject("profileAvatarImagesById")
?.optString(profileId)
?.takeIf { it.isNotBlank() }
}.getOrNull()
} catch (e: org.json.JSONException) {
com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Error parsing inline avatar JSON: ${e.message}")
null
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
com.arflix.tv.util.AppLogger.e("ProfileAvatar", "Unexpected error parsing inline avatar: ${e.message}")
null
}
}
}

Expand Down
Loading
Loading