refactor: enhance authentication and sync logic, improve error handling for TV login
This commit is contained in:
parent
3a472435fe
commit
7c3ba09e05
5 changed files with 202 additions and 77 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Reference in a new issue