Merge branch 'trakttokensync' into cmp-rewrite

This commit is contained in:
tapframe 2026-07-06 01:38:14 +05:30
commit 2dbaeb5942
3 changed files with 204 additions and 2 deletions

View file

@ -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" } }

View file

@ -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<TraktTokenResponse>(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

View file

@ -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<ProviderCredentialRow>()
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