diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 27b3d1f52..d289ea5a4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -14,7 +14,9 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_profile_pin_cache", "nuvio_theme_settings", "nuvio_poster_card_style", + "nuvio_debrid_settings", "nuvio_mdblist_settings", + "nuvio_downloads", "nuvio_trakt_auth", "nuvio_trakt_library", "nuvio_trakt_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index de8e76b1e..08751085a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -30,6 +30,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -150,6 +151,20 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId))) + ?.apply() + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) @@ -249,4 +264,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 32b14c525..cd96fbd9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -466,21 +466,26 @@ fun App() { LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { val cachedProfiles = profileState.profiles - val allowOfflineProfileAccess = + val hasCachedProfileAccess = cachedProfiles.isNotEmpty() && - authState !is AuthState.Authenticated && - networkStatusUiState.condition != NetworkCondition.Online + authState !is AuthState.Authenticated + val allowCachedProfileAccess = + hasCachedProfileAccess && + ( + networkStatusUiState.condition != NetworkCondition.Online || + gateScreen != AppGateScreen.Auth.name + ) when (authState) { is AuthState.Loading -> { - if (allowOfflineProfileAccess) { + if (hasCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { gateScreen = AppGateScreen.Loading.name } } is AuthState.Unauthenticated -> { - if (allowOfflineProfileAccess) { + if (allowCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { ProfileRepository.clearInMemory() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt index 295179d8e..1b0db4b14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamItem import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable internal interface DebridProviderApi { val provider: DebridProvider @@ -35,6 +36,7 @@ internal object DebridProviderApis { } } +@Serializable internal data class DebridDeviceAuthorization( val providerId: String, val deviceCode: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 7d68db489..700914e34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -35,6 +35,9 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) + fun loadPendingDeviceAuthorization(providerId: String): String? + fun savePendingDeviceAuthorization(providerId: String, payload: String) + fun clearPendingDeviceAuthorization(providerId: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 646a1597e..4b812d927 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -59,6 +59,7 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag @@ -74,6 +75,9 @@ import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear @@ -1467,12 +1471,21 @@ private fun DebridDeviceAuthDialog( isStarting = true isPolling = false statusMessage = null + if (restartNonce == 0) { + loadPendingDeviceAuthorization(provider.id)?.let { pendingSession -> + session = pendingSession + isStarting = false + statusMessage = waitingMessage + return@LaunchedEffect + } + } val startResult = runCatching { DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") }.onFailure { error -> if (error is CancellationException) throw error } session = startResult.getOrNull() + session?.let(::savePendingDeviceAuthorization) isStarting = false statusMessage = if (session == null) { startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") } @@ -1504,6 +1517,7 @@ private fun DebridDeviceAuthDialog( isPolling = false when (result) { is DebridDeviceAuthorizationTokenResult.Authorized -> { + clearPendingDeviceAuthorization(provider.id) onConnected(result.accessToken) onDismiss() return@LaunchedEffect @@ -1514,16 +1528,19 @@ private fun DebridDeviceAuthDialog( } DebridDeviceAuthorizationTokenResult.Expired -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = expiredMessage return@LaunchedEffect } is DebridDeviceAuthorizationTokenResult.Failed -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage) return@LaunchedEffect } DebridDeviceAuthorizationTokenResult.Unsupported -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = failedMessage return@LaunchedEffect } @@ -1621,6 +1638,7 @@ private fun DebridDeviceAuthDialog( if (isConnected) { Button( onClick = { + clearPendingDeviceAuthorization(provider.id) onDisconnect() onDismiss() }, @@ -1629,7 +1647,12 @@ private fun DebridDeviceAuthDialog( } } if (!isConnected && !isStarting && session == null) { - TextButton(onClick = { restartNonce += 1 }) { + TextButton( + onClick = { + clearPendingDeviceAuthorization(provider.id) + restartNonce += 1 + }, + ) { Text(stringResource(Res.string.action_retry)) } } @@ -1649,6 +1672,34 @@ private fun DebridDeviceAuthDialog( } } +private val debridDeviceAuthorizationJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private fun loadPendingDeviceAuthorization(providerId: String): DebridDeviceAuthorization? = + DebridSettingsStorage.loadPendingDeviceAuthorization(providerId) + .orEmpty() + .trim() + .takeIf { it.isNotBlank() } + ?.let { payload -> + runCatching { + debridDeviceAuthorizationJson.decodeFromString(payload) + }.getOrNull() + } + ?.takeIf { it.providerId == providerId } + +private fun savePendingDeviceAuthorization(session: DebridDeviceAuthorization) { + DebridSettingsStorage.savePendingDeviceAuthorization( + providerId = session.providerId, + payload = debridDeviceAuthorizationJson.encodeToString(session), + ) +} + +private fun clearPendingDeviceAuthorization(providerId: String) { + DebridSettingsStorage.clearPendingDeviceAuthorization(providerId) +} + private fun Throwable.isCancelledHttpRequest(): Boolean { val text = listOfNotNull(message, toString()) .joinToString(" ") diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index d11d9c644..bc68c7e45 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -28,6 +28,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -142,6 +143,19 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey( + ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId)), + ) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) @@ -232,4 +246,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } }