diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c09b2c4b7..69bf46b64 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1468,6 +1468,7 @@ Stream Embedded Authorization denied + Your Trakt authorization is no longer valid. Reconnect your Trakt account. Complete Trakt sign in in your browser Connected to Trakt Disconnected from Trakt 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 4f459078a..f81af882a 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 @@ -12,7 +12,6 @@ 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.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktCredentialSync import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.effectiveLibrarySourceMode @@ -39,7 +38,6 @@ internal enum class ProfileSyncStep { Addons, Plugins, ProfileSettings, - TraktCredentials, Library, ActiveWatchSource, Collections, @@ -50,7 +48,6 @@ internal data class ProfileSyncOperations( val pullAddons: suspend (Int) -> Unit, val pullPlugins: suspend (Int) -> Unit, val pullProfileSettings: suspend (Int) -> Unit, - val pullTraktCredentials: suspend (Int) -> Unit, val pullLibrary: suspend (Int) -> Unit, val refreshActiveWatchSource: suspend (Int) -> Unit, val pullCollections: suspend (Int) -> Unit, @@ -94,16 +91,7 @@ internal suspend fun runOrderedProfileSync( runStep(ProfileSyncStep.Plugins, operations.pullPlugins) } - coroutineScope { - val settingsJob = launch { - runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) - } - val credentialsJob = launch { - runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials) - } - settingsJob.join() - credentialsJob.join() - } + runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) coroutineScope { launch { @@ -229,7 +217,6 @@ object SyncManager { pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) }, pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) }, pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) }, - pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) }, pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) }, refreshActiveWatchSource = { profileId -> val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) 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 9e940ee9e..20fa9eb50 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 @@ -3,6 +3,8 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.profiles.ProfileRepository import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.CancellationException @@ -13,6 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -35,6 +39,7 @@ object TraktAuthRepository { encodeDefaults = true } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshMutex = Mutex() private val _uiState = MutableStateFlow(TraktAuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -65,24 +70,6 @@ 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() @@ -299,7 +286,6 @@ object TraktAuthRepository { ) persist() refreshUserSettings() - TraktCredentialSync.pushCurrentToRemote() publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -341,12 +327,14 @@ object TraktAuthRepository { ) } - private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean { - if (!hasRequiredCredentials()) return false - val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return false + private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock { + if (!hasRequiredCredentials()) return@withLock false + val profileId = ProfileRepository.activeProfileId + val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } + ?: return@withLock false if (!force && !isTokenExpiredOrExpiring(authState)) { - return true + return@withLock true } val body = json.encodeToString( @@ -359,29 +347,42 @@ object TraktAuthRepository { ) val response = runCatching { - httpPostJsonWithHeaders( + httpRequestRaw( + method = "POST", url = "$BASE_URL/oauth/token", body = body, - headers = emptyMap(), + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), ) }.onFailure { error -> if (error is CancellationException) throw error - log.w { "Trakt token refresh failed: ${error.message}" } - }.getOrNull() + log.w { "Trakt token refresh transport failure: ${error.message}" } + }.getOrNull() ?: return@withLock false - if (response == null) { - if (recoverFromRemoteCredentials(refreshToken)) return true - return false + if (ProfileRepository.activeProfileId != profileId || authState.refreshToken != refreshToken) { + return@withLock false + } + + when (traktTokenRefreshResponseAction(response.status)) { + TraktTokenRefreshResponseAction.INVALIDATE -> { + log.w { "Trakt rejected the refresh token with HTTP 400; clearing local credentials" } + invalidateCredentials(profileId) + return@withLock false + } + + TraktTokenRefreshResponseAction.TRANSIENT_FAILURE -> { + log.w { "Trakt token refresh failed with HTTP ${response.status}" } + return@withLock false + } + + TraktTokenRefreshResponseAction.ACCEPT -> Unit } val parsed = runCatching { - json.decodeFromString(response) - }.getOrNull() - - if (parsed == null) { - if (recoverFromRemoteCredentials(refreshToken)) return true - return false - } + json.decodeFromString(response.body) + }.getOrNull() ?: return@withLock false authState = authState.copy( accessToken = parsed.accessToken, @@ -391,9 +392,19 @@ object TraktAuthRepository { expiresIn = parsed.expiresIn, ) persist() - TraktCredentialSync.pushCurrentToRemote() publish() - return true + true + } + + private suspend fun invalidateCredentials(profileId: Int) { + authState = TraktAuthState() + persist() + publish( + isLoading = false, + statusMessage = null, + errorMessage = localizedString(Res.string.trakt_authorization_expired_reconnect), + ) + TraktCredentialSync.deleteRemote(profileId) } private fun loadFromDisk() { @@ -475,22 +486,18 @@ object TraktAuthRepository { 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("|") +internal enum class TraktTokenRefreshResponseAction { + ACCEPT, + INVALIDATE, + TRANSIENT_FAILURE, +} + +internal fun traktTokenRefreshResponseAction(status: Int): TraktTokenRefreshResponseAction = when { + status == 400 -> TraktTokenRefreshResponseAction.INVALIDATE + status in 200..299 -> TraktTokenRefreshResponseAction.ACCEPT + else -> TraktTokenRefreshResponseAction.TRANSIENT_FAILURE } @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 index b25746472..40bbb45e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt @@ -8,100 +8,17 @@ 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.CancellationException 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 = - try { - pullFromRemoteOrThrow(profileId) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "pullFromRemote(profileId=$profileId) failed" } - false - } - - internal suspend fun pullFromRemoteOrThrow( - profileId: Int = ProfileRepository.activeProfileId, - ): Boolean = mutex.withLock { - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false - val accountId = authState.userId - if (ProfileRepository.activeProfileId != profileId) return@withLock false - - val params = buildJsonObject { - put("p_profile_id", profileId) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params) - val currentAuthState = AuthRepository.state.value - if ( - currentAuthState !is AuthState.Authenticated || - currentAuthState.isAnonymous || - currentAuthState.userId != accountId || - ProfileRepository.activeProfileId != profileId - ) { - throw CancellationException("Trakt credential pull target changed") - } - val rows = result.decodeList() - val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) } - ?: return@withLock false - val remoteState = row.credentialJson.toTraktAuthState() - ?: error("Remote Trakt credential payload is invalid") - TraktAuthRepository.replaceStateFromSync(remoteState) - } - suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = mutex.withLock { val authState = AuthRepository.state.value @@ -121,45 +38,3 @@ object TraktCredentialSync { } } } - -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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt index 7eb617ac4..3168d35eb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -14,7 +14,6 @@ class SyncManagerTest { fun `source prerequisites finish before source dependent pulls`() = runBlocking { val events = mutableListOf() var profileSettingsApplied = false - var traktCredentialsApplied = false runOrderedProfileSync( profileId = 7, @@ -28,20 +27,12 @@ class SyncManagerTest { profileSettingsApplied = true events += "settings:end" }, - pullTraktCredentials = { - events += "credentials:start" - yield() - traktCredentialsApplied = true - events += "credentials:end" - }, pullLibrary = { assertTrue(profileSettingsApplied) - assertTrue(traktCredentialsApplied) events += "library" }, refreshActiveWatchSource = { assertTrue(profileSettingsApplied) - assertTrue(traktCredentialsApplied) events += "active-watch-source" }, pullCollections = { events += "collections" }, @@ -50,10 +41,7 @@ class SyncManagerTest { onFailure = { _, error -> throw error }, ) - val lastPrerequisite = maxOf( - events.indexOf("settings:end"), - events.indexOf("credentials:end"), - ) + val lastPrerequisite = events.indexOf("settings:end") assertTrue(events.indexOf("library") > lastPrerequisite) assertTrue(events.indexOf("active-watch-source") > lastPrerequisite) assertEquals(1, events.count { it == "active-watch-source" }) @@ -72,7 +60,7 @@ class SyncManagerTest { assertTrue("plugins" !in events) assertTrue(events.indexOf("settings") < events.indexOf("library")) - assertTrue(events.indexOf("credentials") < events.indexOf("active-watch-source")) + assertTrue(events.indexOf("settings") < events.indexOf("active-watch-source")) } @Test @@ -175,7 +163,6 @@ class SyncManagerTest { pullAddons = { events += "addons" }, pullPlugins = { events += "plugins" }, pullProfileSettings = { events += "settings" }, - pullTraktCredentials = { events += "credentials" }, pullLibrary = { events += "library" }, refreshActiveWatchSource = { events += "active-watch-source" }, pullCollections = { events += "collections" }, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt new file mode 100644 index 000000000..7bb1c7e27 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktAuthRepositoryTest { + + @Test + fun `HTTP 400 permanently invalidates a rejected refresh token`() { + assertEquals( + TraktTokenRefreshResponseAction.INVALIDATE, + traktTokenRefreshResponseAction(400), + ) + } + + @Test + fun `successful token responses are accepted`() { + assertEquals( + TraktTokenRefreshResponseAction.ACCEPT, + traktTokenRefreshResponseAction(200), + ) + assertEquals( + TraktTokenRefreshResponseAction.ACCEPT, + traktTokenRefreshResponseAction(201), + ) + } + + @Test + fun `non-400 failures preserve credentials for a later attempt`() { + listOf(401, 429, 500, 503).forEach { status -> + assertEquals( + TraktTokenRefreshResponseAction.TRANSIENT_FAILURE, + traktTokenRefreshResponseAction(status), + ) + } + } +}