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),
+ )
+ }
+ }
+}