From 2b2d1f56ad634def46c578acac0af242bcf43a90 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:28:00 +0530 Subject: [PATCH] Fix Android process-death recovery Persist pending debrid device-code authorization so returning from the browser after Android process death can continue polling the same session. Recover cached profiles during auth rehydration instead of briefly dumping users to login after background kills. Include debrid and downloads preferences in local account cleanup on sign-out. --- ...PlatformLocalAccountDataCleaner.android.kt | 2 + .../debrid/DebridSettingsStorage.android.kt | 21 ++++++++ .../commonMain/kotlin/com/nuvio/app/App.kt | 15 ++++-- .../app/features/debrid/DebridProviderApis.kt | 2 + .../features/debrid/DebridSettingsStorage.kt | 3 ++ .../features/settings/DebridSettingsPage.kt | 53 ++++++++++++++++++- .../debrid/DebridSettingsStorage.ios.kt | 20 +++++++ 7 files changed, 110 insertions(+), 6 deletions(-) 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" + } }