From f3f120a57d4e254174b5100ce5a27fd9b73f81a2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:36:32 +0530 Subject: [PATCH] Sync Trakt credentials across clients --- .../com/nuvio/app/core/sync/SyncManager.kt | 8 + .../app/features/trakt/TraktAuthRepository.kt | 52 ++++++- .../app/features/trakt/TraktCredentialSync.kt | 146 ++++++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index c611aa520..24970f26d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -10,6 +10,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.trakt.TraktCredentialSync import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -49,6 +50,10 @@ object SyncManager { .onFailure { log.e(it) { "Plugin pull failed" } } } + runCatching { TraktCredentialSync.pullFromRemote(profileId) } + .onSuccess { applied -> log.i { "pullAllForProfile — Trakt credential pull completed applied=$applied" } } + .onFailure { log.e(it) { "Trakt credential pull failed" } } + log.i { "pullAllForProfile — launching remaining pulls in parallel" } launch { runCatching { LibraryRepository.pullFromServer(profileId) } @@ -153,6 +158,9 @@ object SyncManager { scope.launch { log.i { "pullForegroundForProfile($profileId) — syncing watch progress, library, collections, and home settings" } + runCatching { TraktCredentialSync.pullFromRemote(profileId) } + .onFailure { log.e(it) { "Foreground Trakt credential pull failed" } } + launch { runCatching { LibraryRepository.pullFromServer(profileId) } .onFailure { log.e(it) { "Foreground library pull failed" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index cba36b92a..9e940ee9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -65,6 +65,24 @@ object TraktAuthRepository { return _uiState.value } + internal fun currentStateForSync(): TraktAuthState { + ensureLoaded() + return authState + } + + internal fun replaceStateFromSync(state: TraktAuthState): Boolean { + ensureLoaded() + val syncedState = state.copy( + pendingAuthorizationState = null, + pendingAuthorizationStartedAtMillis = null, + ) + if (authState.syncSignature() == syncedState.syncSignature()) return false + authState = syncedState + persist() + publish(statusMessage = null, errorMessage = null) + return true + } + fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() @@ -281,6 +299,7 @@ object TraktAuthRepository { ) persist() refreshUserSettings() + TraktCredentialSync.pushCurrentToRemote() publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -312,6 +331,7 @@ object TraktAuthRepository { } } + TraktCredentialSync.deleteRemote() authState = TraktAuthState() persist() publish( @@ -347,11 +367,21 @@ object TraktAuthRepository { }.onFailure { error -> if (error is CancellationException) throw error log.w { "Trakt token refresh failed: ${error.message}" } - }.getOrNull() ?: return false + }.getOrNull() + + if (response == null) { + if (recoverFromRemoteCredentials(refreshToken)) return true + return false + } val parsed = runCatching { json.decodeFromString(response) - }.getOrNull() ?: return false + }.getOrNull() + + if (parsed == null) { + if (recoverFromRemoteCredentials(refreshToken)) return true + return false + } authState = authState.copy( accessToken = parsed.accessToken, @@ -361,6 +391,7 @@ object TraktAuthRepository { expiresIn = parsed.expiresIn, ) persist() + TraktCredentialSync.pushCurrentToRemote() publish() return true } @@ -443,6 +474,23 @@ object TraktAuthRepository { val nowSeconds = TraktPlatformClock.nowEpochMs() / 1_000L return nowSeconds >= (expiresAtSeconds - 60) } + + private suspend fun recoverFromRemoteCredentials(staleRefreshToken: String): Boolean { + val pulled = TraktCredentialSync.pullFromRemote() + if (!pulled) return false + return authState.isAuthenticated && authState.refreshToken != staleRefreshToken + } + + private fun TraktAuthState.syncSignature(): String = + listOf( + accessToken.orEmpty(), + refreshToken.orEmpty(), + tokenType.orEmpty(), + createdAt?.toString().orEmpty(), + expiresIn?.toString().orEmpty(), + username.orEmpty(), + userSlug.orEmpty(), + ).joinToString("|") } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt new file mode 100644 index 000000000..2efc6ec4e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt @@ -0,0 +1,146 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId +import com.nuvio.app.features.profiles.ProfileRepository +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.rpc +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +private const val TRAKT_PROVIDER = "trakt" +private const val TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS = 86_400 + +@Serializable +private data class ProviderCredentialRow( + val provider: String, + @SerialName("credential_json") val credentialJson: JsonObject, + @SerialName("updated_at") val updatedAt: String? = null, +) + +object TraktCredentialSync { + private val log = Logger.withTag("TraktCredentialSync") + private val mutex = Mutex() + + suspend fun pushCurrentToRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = + mutex.withLock { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false + + val state = TraktAuthRepository.currentStateForSync() + val credentialJson = state.toCredentialJson() ?: return@withLock false + runCatching { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_credentials", buildJsonArray { + addJsonObject { + put("provider", TRAKT_PROVIDER) + put("credential_json", credentialJson) + } + }) + putSyncOriginClientId() + } + SupabaseProvider.client.postgrest.rpc("sync_push_provider_credentials", params) + true + }.getOrElse { error -> + log.e(error) { "pushCurrentToRemote(profileId=$profileId) failed" } + false + } + } + + suspend fun pullFromRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = + mutex.withLock { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false + + runCatching { + val params = buildJsonObject { + put("p_profile_id", profileId) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params) + val rows = result.decodeList() + val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) } + ?: return@runCatching false + val remoteState = row.credentialJson.toTraktAuthState() ?: return@runCatching false + TraktAuthRepository.replaceStateFromSync(remoteState) + }.getOrElse { error -> + log.e(error) { "pullFromRemote(profileId=$profileId) failed" } + false + } + } + + suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = + mutex.withLock { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false + + runCatching { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_provider", TRAKT_PROVIDER) + putSyncOriginClientId() + } + SupabaseProvider.client.postgrest.rpc("sync_delete_provider_credentials", params) + true + }.getOrElse { error -> + log.e(error) { "deleteRemote(profileId=$profileId) failed" } + false + } + } +} + +private fun TraktAuthState.toCredentialJson(): JsonObject? { + val accessTokenValue = accessToken?.takeIf { it.isNotBlank() } ?: return null + val refreshTokenValue = refreshToken?.takeIf { it.isNotBlank() } ?: return null + return buildJsonObject { + put("access_token", accessTokenValue) + put("refresh_token", refreshTokenValue) + put("token_type", tokenType ?: "bearer") + put("created_at", createdAt ?: (TraktPlatformClock.nowEpochMs() / 1_000L)) + put("expires_in", normalizeTraktTokenLifetime(expiresIn ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS)) + username?.takeIf { it.isNotBlank() }?.let { put("username", it) } + userSlug?.takeIf { it.isNotBlank() }?.let { put("user_slug", it) } + } +} + +private fun JsonObject.toTraktAuthState(): TraktAuthState? { + val accessTokenValue = stringValue("access_token")?.takeIf { it.isNotBlank() } ?: return null + val refreshTokenValue = stringValue("refresh_token")?.takeIf { it.isNotBlank() } ?: return null + return TraktAuthState( + accessToken = accessTokenValue, + refreshToken = refreshTokenValue, + tokenType = stringValue("token_type") ?: "bearer", + createdAt = longValue("created_at") ?: (TraktPlatformClock.nowEpochMs() / 1_000L), + expiresIn = normalizeTraktTokenLifetime(intValue("expires_in") ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS), + username = stringValue("username"), + userSlug = stringValue("user_slug"), + ) +} + +private fun normalizeTraktTokenLifetime(expiresIn: Int): Int { + if (expiresIn <= 0) return TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS + return expiresIn.coerceAtMost(TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS) +} + +private fun JsonObject.stringValue(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull + +private fun JsonObject.longValue(key: String): Long? = + this[key]?.jsonPrimitive?.longOrNull + +private fun JsonObject.intValue(key: String): Int? = + this[key]?.jsonPrimitive?.intOrNull