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.
This commit is contained in:
tapframe 2026-06-11 00:28:00 +05:30
parent 34c565e89f
commit 2b2d1f56ad
7 changed files with 110 additions and 6 deletions

View file

@ -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",

View file

@ -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<String> =
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"
}
}

View file

@ -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()

View file

@ -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,

View file

@ -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)
}

View file

@ -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<DebridDeviceAuthorization>(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(" ")

View file

@ -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<String> =
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"
}
}