refactor: enhance authentication and sync logic, improve error handling for TV login

This commit is contained in:
tapframe 2026-02-17 14:59:48 +05:30
parent 3a472435fe
commit 7c3ba09e05
5 changed files with 202 additions and 77 deletions

View file

@ -43,6 +43,7 @@ class AuthManager @Inject constructor(
val authState: StateFlow<AuthState> = _authState.asStateFlow()
private var cachedEffectiveUserId: String? = null
private var cachedEffectiveUserSourceUserId: String? = null
init {
observeSessionStatus()
@ -55,6 +56,10 @@ class AuthManager @Inject constructor(
is SessionStatus.Authenticated -> {
val user = auth.currentUserOrNull()
if (user != null) {
if (cachedEffectiveUserSourceUserId != user.id) {
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
}
val isAnonymous = user.email.isNullOrBlank()
_authState.value = if (isAnonymous) {
AuthState.Anonymous(userId = user.id)
@ -65,6 +70,7 @@ class AuthManager @Inject constructor(
}
is SessionStatus.NotAuthenticated -> {
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
_authState.value = AuthState.SignedOut
}
is SessionStatus.Initializing -> {
@ -92,12 +98,17 @@ class AuthManager @Inject constructor(
* For direct users, returns their own user ID.
*/
suspend fun getEffectiveUserId(): String? {
cachedEffectiveUserId?.let { return it }
val userId = currentUserId ?: return null
if (cachedEffectiveUserSourceUserId != userId) {
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
}
cachedEffectiveUserId?.let { return it }
return try {
val result = postgrest.rpc("get_sync_owner")
val effectiveId = result.decodeAs<String>()
cachedEffectiveUserId = effectiveId
cachedEffectiveUserSourceUserId = userId
effectiveId
} catch (e: Exception) {
Log.e(TAG, "Failed to get effective user ID, falling back to own ID", e)
@ -148,29 +159,66 @@ class AuthManager @Inject constructor(
Log.e(TAG, "Sign out failed", e)
}
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
}
fun clearEffectiveUserIdCache() {
cachedEffectiveUserId = null
cachedEffectiveUserSourceUserId = null
}
suspend fun startTvLoginSession(deviceNonce: String, deviceName: String?, redirectBaseUrl: String): Result<TvLoginStartResult> {
return try {
val params = buildJsonObject {
put("p_device_nonce", deviceNonce)
put("p_redirect_base_url", redirectBaseUrl)
if (!deviceName.isNullOrBlank()) put("p_device_name", deviceName)
}
val response = postgrest.rpc("start_tv_login_session", params)
val result = response.decodeList<TvLoginStartResult>().firstOrNull()
?: return Result.failure(Exception("Empty response from start_tv_login_session"))
Result.success(result)
Result.success(
startTvLoginSessionRpc(
deviceNonce = deviceNonce,
deviceName = deviceName,
redirectBaseUrl = redirectBaseUrl
)
)
} catch (e: Exception) {
val message = e.message.orEmpty().lowercase()
val shouldRetryLegacySignature = !deviceName.isNullOrBlank() &&
message.contains("could not find the function") &&
message.contains("start_tv_login_session") &&
message.contains("p_device_name")
if (shouldRetryLegacySignature) {
return try {
Log.w(TAG, "start_tv_login_session legacy signature detected; retrying without p_device_name")
Result.success(
startTvLoginSessionRpc(
deviceNonce = deviceNonce,
deviceName = null,
redirectBaseUrl = redirectBaseUrl
)
)
} catch (retryError: Exception) {
Log.e(TAG, "Failed to start TV login session after legacy retry", retryError)
Result.failure(retryError)
}
}
Log.e(TAG, "Failed to start TV login session", e)
Result.failure(e)
}
}
private suspend fun startTvLoginSessionRpc(
deviceNonce: String,
deviceName: String?,
redirectBaseUrl: String
): TvLoginStartResult {
val params = buildJsonObject {
put("p_device_nonce", deviceNonce)
put("p_redirect_base_url", redirectBaseUrl)
if (!deviceName.isNullOrBlank()) put("p_device_name", deviceName)
}
val response = postgrest.rpc("start_tv_login_session", params)
return response.decodeList<TvLoginStartResult>().firstOrNull()
?: throw Exception("Empty response from start_tv_login_session")
}
suspend fun pollTvLoginSession(code: String, deviceNonce: String): Result<TvLoginPollResult> {
return try {
val params = buildJsonObject {

View file

@ -139,13 +139,29 @@ class StartupSyncService @Inject constructor(
val isTraktConnected = traktAuthDataStore.isAuthenticated.first()
Log.d(TAG, "Watch progress sync: isTraktConnected=$isTraktConnected")
if (!isTraktConnected) {
// Re-check before each pull to avoid stale decisions when Trakt auth flips mid-sync.
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Skipping watch progress & library sync (Trakt connected during startup sync)")
return Result.success(Unit)
}
watchProgressRepository.isSyncingFromRemote = true
val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it }
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Discarding account watch progress pull (Trakt connected during pull)")
watchProgressRepository.isSyncingFromRemote = false
return Result.success(Unit)
}
Log.d(TAG, "Pulled ${remoteEntries.size} watch progress entries from remote")
watchProgressPreferences.replaceWithRemoteEntries(remoteEntries.toMap())
Log.d(TAG, "Reconciled local watch progress with ${remoteEntries.size} remote entries")
watchProgressRepository.isSyncingFromRemote = false
if (traktAuthDataStore.isAuthenticated.first()) {
Log.d(TAG, "Skipping library/watch history sync (Trakt connected during startup sync)")
return Result.success(Unit)
}
libraryRepository.isSyncingFromRemote = true
val remoteLibraryItems = librarySyncService.pullFromRemote().getOrElse { throw it }
Log.d(TAG, "Pulled ${remoteLibraryItems.size} library items from remote")

View file

@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.reflect.TypeToken
import com.nuvio.tv.domain.model.WatchProgress
import dagger.hilt.android.qualifiers.ApplicationContext
@ -39,29 +41,6 @@ class WatchProgressPreferences @Inject constructor(
private val maxStoredEntries = 300
private data class StoredWatchProgress(
val contentId: String? = null,
val contentType: String? = null,
val name: String? = null,
val poster: String? = null,
val backdrop: String? = null,
val logo: String? = null,
val videoId: String? = null,
val season: Int? = null,
val episode: Int? = null,
val episodeTitle: String? = null,
val position: Long? = null,
val duration: Long? = null,
val lastWatched: Long? = null,
val addonBaseUrl: String? = null,
val progressPercent: Float? = null,
val source: String? = null,
val traktPlaybackId: Long? = null,
val traktMovieId: Int? = null,
val traktShowId: Int? = null,
val traktEpisodeId: Int? = null
)
/**
* Get all watch progress items, sorted by last watched (most recent first)
* For series, only returns the series-level entry (not individual episode entries)
@ -270,62 +249,137 @@ class WatchProgressPreferences @Inject constructor(
private fun parseProgressMap(json: String): Map<String, WatchProgress> {
return try {
val type = object : TypeToken<Map<String, StoredWatchProgress>>() {}.type
val rawMap: Map<String, StoredWatchProgress> = gson.fromJson(json, type) ?: emptyMap()
rawMap.mapNotNull { (key, rawEntry) ->
rawEntry.toWatchProgressOrNull(key)?.let { key to it }
}.toMap()
// Parse entry-by-entry so one malformed value doesn't wipe the entire map.
val root = gson.fromJson(json, JsonObject::class.java) ?: return emptyMap()
val parsed = mutableMapOf<String, WatchProgress>()
root.entrySet().forEach { (key, value) ->
runCatching {
parseWatchProgressFromJson(value)
}.onSuccess { watchProgress ->
if (watchProgress != null) parsed[key] = watchProgress
}.onFailure {
Log.w(TAG, "Skipping malformed watch progress entry for key=$key")
}
}
parsed
} catch (e: Exception) {
Log.e(TAG, "Failed to parse progress data", e)
emptyMap()
// Backward compatibility with previously stored direct WatchProgress payloads.
runCatching {
val fallbackType = object : TypeToken<Map<String, WatchProgress>>() {}.type
gson.fromJson<Map<String, WatchProgress>>(json, fallbackType) ?: emptyMap()
}.getOrElse { emptyMap() }
}
}
private fun StoredWatchProgress.toWatchProgressOrNull(key: String): WatchProgress? {
val resolvedContentId = contentId?.takeIf { it.isNotBlank() }
val resolvedContentType = contentType?.takeIf { it.isNotBlank() }
val resolvedVideoId = videoId?.takeIf { it.isNotBlank() } ?: resolvedContentId
val resolvedLastWatched = lastWatched
if (resolvedContentId == null || resolvedContentType == null || resolvedVideoId == null || resolvedLastWatched == null) {
Log.w(TAG, "Dropping invalid watch progress entry for key=$key")
return null
}
private fun parseWatchProgressFromJson(value: JsonElement): WatchProgress? {
val obj = when {
value.isJsonObject -> value.asJsonObject
value.isJsonPrimitive && value.asJsonPrimitive.isString -> {
runCatching { gson.fromJson(value.asString, JsonObject::class.java) }.getOrNull()
}
else -> null
} ?: return null
val contentId = obj.getString("contentId", "content_id")?.takeIf { it.isNotBlank() } ?: return null
val contentType = obj.getString("contentType", "content_type")?.takeIf { it.isNotBlank() } ?: return null
val videoId = obj.getString("videoId", "video_id")?.takeIf { it.isNotBlank() } ?: contentId
val lastWatched = obj.getLong("lastWatched", "last_watched") ?: return null
return WatchProgress(
contentId = resolvedContentId,
contentType = resolvedContentType,
name = name.orEmpty(),
poster = poster,
backdrop = backdrop,
logo = logo,
videoId = resolvedVideoId,
season = season,
episode = episode,
episodeTitle = episodeTitle,
position = position ?: 0L,
duration = duration ?: 0L,
lastWatched = resolvedLastWatched,
addonBaseUrl = addonBaseUrl,
progressPercent = progressPercent,
source = source?.takeIf { it.isNotBlank() } ?: WatchProgress.SOURCE_LOCAL,
traktPlaybackId = traktPlaybackId,
traktMovieId = traktMovieId,
traktShowId = traktShowId,
traktEpisodeId = traktEpisodeId
contentId = contentId,
contentType = contentType,
name = obj.getString("name").orEmpty(),
poster = obj.getString("poster"),
backdrop = obj.getString("backdrop"),
logo = obj.getString("logo"),
videoId = videoId,
season = obj.getInt("season"),
episode = obj.getInt("episode"),
episodeTitle = obj.getString("episodeTitle", "episode_title"),
position = obj.getLong("position") ?: 0L,
duration = obj.getLong("duration") ?: 0L,
lastWatched = lastWatched,
addonBaseUrl = obj.getString("addonBaseUrl", "addon_base_url"),
progressPercent = obj.getFloat("progressPercent", "progress_percent"),
source = obj.getString("source")?.takeIf { it.isNotBlank() } ?: WatchProgress.SOURCE_LOCAL,
traktPlaybackId = obj.getLong("traktPlaybackId", "trakt_playback_id"),
traktMovieId = obj.getInt("traktMovieId", "trakt_movie_id"),
traktShowId = obj.getInt("traktShowId", "trakt_show_id"),
traktEpisodeId = obj.getInt("traktEpisodeId", "trakt_episode_id")
)
}
private fun JsonObject.getString(vararg keys: String): String? {
keys.forEach { key ->
val value = this.get(key) ?: return@forEach
if (value.isJsonNull) return@forEach
return runCatching { value.asString }.getOrNull()
}
return null
}
private fun JsonObject.getLong(vararg keys: String): Long? {
keys.forEach { key ->
val value = this.get(key) ?: return@forEach
if (value.isJsonNull) return@forEach
runCatching { value.asLong }.getOrNull()?.let { return it }
runCatching { value.asDouble.toLong() }.getOrNull()?.let { return it }
runCatching { value.asString.toLong() }.getOrNull()?.let { return it }
}
return null
}
private fun JsonObject.getInt(vararg keys: String): Int? {
keys.forEach { key ->
val value = this.get(key) ?: return@forEach
if (value.isJsonNull) return@forEach
runCatching { value.asInt }.getOrNull()?.let { return it }
runCatching { value.asDouble.toInt() }.getOrNull()?.let { return it }
runCatching { value.asString.toInt() }.getOrNull()?.let { return it }
}
return null
}
private fun JsonObject.getFloat(vararg keys: String): Float? {
keys.forEach { key ->
val value = this.get(key) ?: return@forEach
if (value.isJsonNull) return@forEach
runCatching { value.asFloat }.getOrNull()?.let { return it }
runCatching { value.asDouble.toFloat() }.getOrNull()?.let { return it }
runCatching { value.asString.toFloat() }.getOrNull()?.let { return it }
}
return null
}
private fun pruneOldItems(map: MutableMap<String, WatchProgress>): Map<String, WatchProgress> {
if (map.isEmpty()) return map
val keepContentIds = map.values
val latestByContent = map.values
.groupBy { it.contentId }
.mapValues { (_, items) -> items.maxOf { it.lastWatched } }
val inProgressContentIds = map.values
.asSequence()
.filter { it.isInProgress() }
.map { it.contentId }
.toSet()
val sortedContentIds = latestByContent
.entries
.sortedByDescending { it.value }
.take(maxItems)
.map { it.key }
val keepContentIds = buildList {
sortedContentIds
.filter { it in inProgressContentIds }
.forEach { add(it) }
sortedContentIds
.filter { it !in inProgressContentIds }
.forEach { add(it) }
}
.distinct()
.take(maxItems)
val keepContentIdSet = keepContentIds.toSet()
val filteredByContent = map.filterValues { it.contentId in keepContentIdSet }

View file

@ -215,14 +215,10 @@ class WatchProgressRepositoryImpl @Inject constructor(
if (isAuthenticated) {
combine(
traktProgressService.observeAllProgress(),
watchProgressPreferences.allProgress,
metadataState
) { remoteItems, localItems, metadataMap ->
) { remoteItems, metadataMap ->
val mergedByKey = linkedMapOf<String, WatchProgress>()
remoteItems.forEach { mergedByKey[progressKey(it)] = it }
localItems.forEach { local ->
mergedByKey.putIfAbsent(progressKey(local), local)
}
val merged = mergedByKey.values
.sortedByDescending { it.lastWatched }
hydrateMetadata(merged)

View file

@ -104,6 +104,7 @@ class AccountViewModel @Inject constructor(
authManager.signInWithEmail(email, password).fold(
onSuccess = {
pullRemoteData()
loadConnectedStats()
_uiState.update { it.copy(isLoading = false) }
},
onFailure = { e ->
@ -291,6 +292,7 @@ class AccountViewModel @Inject constructor(
authManager.exchangeTvLoginSession(code = code, deviceNonce = nonce).fold(
onSuccess = {
pullRemoteData()
loadConnectedStats()
_uiState.update { it.copy(isLoading = false, qrLoginStatus = "Signed in successfully") }
},
onFailure = { e ->
@ -361,7 +363,8 @@ class AccountViewModel @Inject constructor(
private fun userFriendlyError(e: Throwable): String {
val raw = e.message ?: ""
val message = raw.lowercase()
Log.w("AccountViewModel", "Raw error: $raw", e)
val compactRaw = raw.lineSequence().firstOrNull()?.trim().orEmpty()
Log.w("AccountViewModel", "Raw error: $compactRaw")
return when {
// PIN errors (from PG RAISE EXCEPTION or any wrapper)
@ -386,6 +389,14 @@ class AccountViewModel @Inject constructor(
message.contains("tv login") && message.contains("expired") -> "QR login expired. Please try again."
message.contains("tv login") && message.contains("invalid") -> "Invalid QR login code."
message.contains("tv login") && message.contains("nonce") -> "This QR login was requested from another device."
message.contains("start_tv_login_session") && message.contains("could not find the function") ->
"QR login service is outdated. Reapply TV login SQL setup."
message.contains("gen_random_bytes") && message.contains("does not exist") ->
"QR login backend is missing setup. Update TV login SQL setup."
message.contains("invalid tv login redirect base url") ->
"QR login URL is misconfigured."
message.contains("invalid device nonce") ->
"QR login request was invalid. Please retry."
// Network errors
message.contains("unable to resolve host") || message.contains("no address associated") -> "No internet connection."