fix: per-profile isolation for Trakt auth and storage

1. TraktAuthStorage: Added profileId parameter to loadPayload(profileId)
   and savePayload(profileId, payload) in Android and iOS actual
   implementations, passing it explicitly to ProfileScopedKey.of().

2. TraktAuthRepository: Added currentProfileId and profileGeneration
   for cross-profile operation detection. Methods now accept explicit
   profileId to isolate auth state per profile. loadFromDisk() and
   persist() use the scoped storage key.

3. TraktCredentialSync: Updated to pass profileId to currentStateForSync()
   and replaceStateFromSync().

4. ProfileRepository/LibraryRepository: Updated calls to
   TraktAuthRepository.onProfileChanged(profileId).

5. WatchedRepository/WatchProgressRepository: Fixed
   pullFromServer/forceSnapshotRefreshFromServer to pass profileId
   to TraktAuthRepository.ensureLoaded() before checking
   activeOperationGeneration.

6. SyncManager: Passes profileId to ensureLoaded().
This commit is contained in:
guuilp 2026-07-09 10:11:57 +00:00
parent 824f2a22ad
commit 05a5f9d7d3
8 changed files with 74 additions and 68 deletions

View file

@ -14,13 +14,13 @@ internal actual object TraktAuthStorage {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun loadPayload(profileId: Int): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey, profileId), null)
actual fun savePayload(payload: String) {
actual fun savePayload(profileId: Int, payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.putString(ProfileScopedKey.of(payloadKey, profileId), payload)
?.apply()
}
}

View file

@ -427,7 +427,7 @@ object SyncManager {
continue
}
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
val traktAuthenticated = TraktAuthRepository.isAuthenticated.value

View file

@ -151,7 +151,7 @@ object LibraryRepository {
TraktSettingsRepository.onProfileChanged()
if (!loadFromDisk(profileId)) return
TraktAuthRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged(profileId)
TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()

View file

@ -154,7 +154,7 @@ object ProfileRepository {
persist()
WatchedRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged()
TraktAuthRepository.onProfileChanged(profileIndex)
LibraryRepository.onProfileChanged(profileIndex)
WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex)

View file

@ -48,33 +48,37 @@ object TraktAuthRepository {
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
private var hasLoaded = false
private var currentProfileId: Int = 1
private var profileGeneration: Long = 0L
private var authState = TraktAuthState()
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
fun ensureLoaded(profileId: Int = ProfileRepository.activeProfileId) {
if (hasLoaded && currentProfileId == profileId) return
loadFromDisk(profileId)
}
fun onProfileChanged() {
loadFromDisk()
fun onProfileChanged(profileId: Int) {
loadFromDisk(profileId)
}
fun clearLocalState() {
hasLoaded = false
currentProfileId = 1
profileGeneration += 1L
authState = TraktAuthState()
publish()
}
fun snapshot(): TraktAuthUiState {
ensureLoaded()
fun snapshot(profileId: Int = ProfileRepository.activeProfileId): TraktAuthUiState {
ensureLoaded(profileId)
return _uiState.value
}
fun hasRequiredCredentials(): Boolean =
TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank()
fun onConnectRequested(): String? {
ensureLoaded()
fun onConnectRequested(profileId: Int = ProfileRepository.activeProfileId): String? {
ensureLoaded(profileId)
if (!hasRequiredCredentials()) {
publish(errorMessage = localizedString(Res.string.trakt_missing_credentials))
return null
@ -85,7 +89,7 @@ object TraktAuthRepository {
pendingAuthorizationState = oauthState,
pendingAuthorizationStartedAtMillis = TraktPlatformClock.nowEpochMs(),
)
persist()
persist(profileId)
publish(
statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser),
errorMessage = null,
@ -94,21 +98,21 @@ object TraktAuthRepository {
return buildAuthorizationUrl(oauthState)
}
fun pendingAuthorizationUrl(): String? {
ensureLoaded()
fun pendingAuthorizationUrl(profileId: Int = ProfileRepository.activeProfileId): String? {
ensureLoaded(profileId)
val oauthState = authState.pendingAuthorizationState ?: return null
return buildAuthorizationUrl(oauthState)
}
fun onCancelAuthorization() {
ensureLoaded()
fun onCancelAuthorization(profileId: Int = ProfileRepository.activeProfileId) {
ensureLoaded(profileId)
clearPendingAuthorization()
persist()
persist(profileId)
publish(statusMessage = null, errorMessage = null)
}
fun onCancelDeviceFlow() {
onCancelAuthorization()
fun onCancelDeviceFlow(profileId: Int = ProfileRepository.activeProfileId) {
onCancelAuthorization(profileId)
}
fun onAuthLaunchFailed(reason: String) {
@ -116,7 +120,8 @@ object TraktAuthRepository {
}
fun onAuthCallbackReceived(callbackUrl: String) {
ensureLoaded()
val profileId = ProfileRepository.activeProfileId
ensureLoaded(profileId)
if (!callbackUrl.startsWith("${TraktConfig.REDIRECT_URI}?", ignoreCase = true) &&
!callbackUrl.equals(TraktConfig.REDIRECT_URI, ignoreCase = true)
) {
@ -124,15 +129,15 @@ object TraktAuthRepository {
}
scope.launch {
completeAuthorizationFromCallback(callbackUrl)
completeAuthorizationFromCallback(callbackUrl, profileId)
}
}
suspend fun authorizedHeaders(): Map<String, String>? {
ensureLoaded()
suspend fun authorizedHeaders(profileId: Int = currentProfileId): Map<String, String>? {
ensureLoaded(profileId)
if (!authState.isAuthenticated) return null
val hasValidToken = refreshTokenIfNeeded(force = false)
val hasValidToken = refreshTokenIfNeeded(force = false, profileId = profileId)
if (!hasValidToken) return null
val accessToken = authState.accessToken?.trim().orEmpty()
@ -145,9 +150,9 @@ object TraktAuthRepository {
)
}
suspend fun refreshUserSettings(): String? {
ensureLoaded()
val headers = authorizedHeaders() ?: return null
suspend fun refreshUserSettings(profileId: Int = currentProfileId): String? {
ensureLoaded(profileId)
val headers = authorizedHeaders(profileId) ?: return null
val response = runCatching {
httpGetTextWithHeaders(
url = "$BASE_URL/users/settings",
@ -166,19 +171,19 @@ object TraktAuthRepository {
username = parsed.user?.username,
userSlug = parsed.user?.ids?.slug,
)
persist()
persist(profileId)
publish()
return authState.username
}
fun onDisconnectRequested() {
ensureLoaded()
fun onDisconnectRequested(profileId: Int = currentProfileId) {
ensureLoaded(profileId)
scope.launch {
disconnect()
disconnect(profileId)
}
}
private suspend fun completeAuthorizationFromCallback(callbackUrl: String) {
private suspend fun completeAuthorizationFromCallback(callbackUrl: String, profileId: Int = currentProfileId) {
publish(isLoading = true, errorMessage = null)
val parsedUrl = runCatching { Url(callbackUrl) }
@ -189,7 +194,7 @@ object TraktAuthRepository {
if (parsedUrl == null) {
clearPendingAuthorization()
persist()
persist(profileId)
publish(
isLoading = false,
errorMessage = localizedString(Res.string.trakt_invalid_callback),
@ -202,7 +207,7 @@ object TraktAuthRepository {
val errorDescription = parsedUrl.parameters["error_description"]
?: localizedString(Res.string.trakt_authorization_denied)
clearPendingAuthorization()
persist()
persist(profileId)
publish(
isLoading = false,
errorMessage = errorDescription,
@ -213,7 +218,7 @@ object TraktAuthRepository {
val code = parsedUrl.parameters["code"].orEmpty().trim()
if (code.isBlank()) {
clearPendingAuthorization()
persist()
persist(profileId)
publish(
isLoading = false,
errorMessage = localizedString(Res.string.trakt_missing_auth_code),
@ -225,7 +230,7 @@ object TraktAuthRepository {
val callbackState = parsedUrl.parameters["state"].orEmpty().trim()
if (!expectedState.isNullOrBlank() && callbackState != expectedState) {
clearPendingAuthorization()
persist()
persist(profileId)
publish(
isLoading = false,
errorMessage = localizedString(Res.string.trakt_invalid_callback_state),
@ -233,10 +238,10 @@ object TraktAuthRepository {
return
}
exchangeAuthorizationCode(code)
exchangeAuthorizationCode(code, profileId)
}
private suspend fun exchangeAuthorizationCode(code: String) {
private suspend fun exchangeAuthorizationCode(code: String, profileId: Int = currentProfileId) {
val body = json.encodeToString(
TraktAuthorizationCodeRequest(
code = code,
@ -259,7 +264,7 @@ object TraktAuthRepository {
if (response == null) {
clearPendingAuthorization()
persist()
persist(profileId)
publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_sign_in_complete_failed))
return
}
@ -270,7 +275,7 @@ object TraktAuthRepository {
if (parsed == null) {
clearPendingAuthorization()
persist()
persist(profileId)
publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response))
return
}
@ -284,8 +289,8 @@ object TraktAuthRepository {
pendingAuthorizationState = null,
pendingAuthorizationStartedAtMillis = null,
)
persist()
refreshUserSettings()
persist(profileId)
refreshUserSettings(profileId)
publish(
isLoading = false,
statusMessage = localizedString(Res.string.trakt_connected_status),
@ -293,7 +298,7 @@ object TraktAuthRepository {
)
}
private suspend fun disconnect() {
private suspend fun disconnect(profileId: Int = currentProfileId) {
publish(isLoading = true, errorMessage = null)
val token = authState.accessToken?.takeIf { it.isNotBlank() }
@ -317,9 +322,9 @@ object TraktAuthRepository {
}
}
TraktCredentialSync.deleteRemote()
TraktCredentialSync.deleteRemote(profileId)
authState = TraktAuthState()
persist()
persist(profileId)
publish(
isLoading = false,
statusMessage = localizedString(Res.string.trakt_disconnected_status),
@ -327,9 +332,8 @@ object TraktAuthRepository {
)
}
private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock {
private suspend fun refreshTokenIfNeeded(force: Boolean, profileId: Int = currentProfileId): Boolean = refreshMutex.withLock {
if (!hasRequiredCredentials()) return@withLock false
val profileId = ProfileRepository.activeProfileId
val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() }
?: return@withLock false
@ -391,14 +395,14 @@ object TraktAuthRepository {
createdAt = parsed.createdAt,
expiresIn = parsed.expiresIn,
)
persist()
persist(profileId)
publish()
true
}
private suspend fun invalidateCredentials(profileId: Int) {
authState = TraktAuthState()
persist()
persist(profileId)
publish(
isLoading = false,
statusMessage = null,
@ -407,9 +411,11 @@ object TraktAuthRepository {
TraktCredentialSync.deleteRemote(profileId)
}
private fun loadFromDisk() {
private fun loadFromDisk(profileId: Int) {
currentProfileId = profileId
profileGeneration += 1L
hasLoaded = true
val payload = TraktAuthStorage.loadPayload().orEmpty().trim()
val payload = TraktAuthStorage.loadPayload(profileId).orEmpty().trim()
authState = if (payload.isBlank()) {
TraktAuthState()
} else {
@ -460,8 +466,8 @@ object TraktAuthRepository {
)
}
private fun persist() {
TraktAuthStorage.savePayload(json.encodeToString(authState))
private fun persist(profileId: Int = currentProfileId) {
TraktAuthStorage.savePayload(profileId, json.encodeToString(authState))
}
private fun buildAuthorizationUrl(state: String): String {
@ -486,6 +492,7 @@ object TraktAuthRepository {
return nowSeconds >= (expiresAtSeconds - 60)
}
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
}
internal enum class TraktTokenRefreshResponseAction {
@ -549,4 +556,3 @@ private data class TraktUserDto(
private data class TraktUserIdsDto(
val slug: String? = null,
)
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }

View file

@ -1,6 +1,6 @@
package com.nuvio.app.features.trakt
internal expect object TraktAuthStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
fun loadPayload(profileId: Int): String?
fun savePayload(profileId: Int, payload: String)
}

View file

@ -270,7 +270,7 @@ object WatchedRepository {
)
suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
refreshForSource(
profileId = profileId,
@ -283,7 +283,7 @@ object WatchedRepository {
}
suspend fun forceSnapshotRefreshFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
refreshForSource(
profileId = profileId,
@ -300,7 +300,7 @@ object WatchedRepository {
source: WatchProgressSource,
forceSnapshot: Boolean = true,
): Boolean {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded(profileId)
TraktSettingsRepository.ensureLoaded()
if (ProfileRepository.activeProfileId != profileId) {
log.d { "Skipping watched refresh for inactive profile $profileId" }

View file

@ -6,10 +6,10 @@ import platform.Foundation.NSUserDefaults
internal actual object TraktAuthStorage {
private const val payloadKey = "trakt_auth_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun loadPayload(profileId: Int): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey, profileId))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
actual fun savePayload(profileId: Int, payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey, profileId))
}
}