From 20f8717cf0d3900bb837eab5346acf2d2c7e0d7e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 12:07:29 +0530 Subject: [PATCH] feat(torbox): link device --- .../composeResources/values-no/strings.xml | 17 +- .../composeResources/values/strings.xml | 17 +- .../app/features/debrid/DebridApiClients.kt | 43 +++ .../app/features/debrid/DebridApiModels.kt | 21 ++ .../app/features/debrid/DebridProvider.kt | 7 + .../app/features/debrid/DebridProviderApis.kt | 72 +++++ .../features/settings/DebridSettingsPage.kt | 276 +++++++++++++++++- .../app/features/debrid/DebridProviderTest.kt | 2 + 8 files changed, 445 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 57e7b185..0fa7a5bd 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -593,12 +593,25 @@ Debrid-støtte er eksperimentell og kan endres eller fjernes senere. Aktiver kilder Vis spillbare resultater fra tilkoblede kontoer. - Legg til en API-nøkkel først. + Koble til en Debrid-konto først. Konto Koble til %1$s-kontoen din. + Koble til %1$s-kontoen din i nettleseren. %1$s API-nøkkel Skriv inn API-nøkkelen din for %1$s. Skriv inn %1$s API-nøkkel + Tilkoblet + Koble til %1$s + Koble fra %1$s + Koble fra + %1$s er koblet til på denne enheten. + Starter sikker innlogging... + Åpne lenken og skriv inn denne koden for å godkjenne Nuvio. + Kode kopiert. + Åpne lenke + Venter på godkjenning... + Kunne ikke starte innlogging. + Denne koden er utløpt. Prøv igjen. Umiddelbar avspilling Forbered lenker Løs første kilder før avspilling starter. @@ -1147,7 +1160,7 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strømtypen støttes ikke - Legg til en Debrid API-nøkkel i Innstillinger. + Koble til en Debrid-konto i Innstillinger. Dette Debrid-resultatet er utgått. Oppdaterer strømmer. Kunne ikke løse denne Debrid-strømmen. Kunne ikke åpne ekstern avspiller diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 210c3fb9..01a8c8a7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -594,13 +594,26 @@ Debrid support is experimental and may be kept, changed, or removed later. Enable sources Show playable results from connected accounts. - Add an API key first. + Connect a Debrid account first. Account Connect your %1$s account. + Link your %1$s account in the browser. %1$s API Key Enter your %1$s API key. Enter %1$s API key Not set + Connected + Connect %1$s + Disconnect %1$s + Disconnect + %1$s is connected on this device. + Starting secure sign-in... + Open the link and enter this code to approve Nuvio. + Code copied. + Open link + Waiting for approval... + Could not start sign-in. + This code expired. Try again. Instant Playback Prepare links Resolve the first sources before playback starts. @@ -1152,7 +1165,7 @@ Resume from %1$s SIZE %1$s This stream type is not supported - Add a Debrid API key in Settings. + Connect a Debrid account in Settings. Not cached on Torbox. This Debrid result expired. Refreshing streams. Could not resolve this Debrid stream. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt index fb67eadb..47ddac07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -28,6 +28,26 @@ internal object DebridApiJson { internal object TorboxApiClient { private const val BASE_URL = "https://api.torbox.app" + suspend fun startDeviceAuthorization( + appName: String, + ): DebridApiResponse> = + requestWithoutAuth( + method = "GET", + url = "$BASE_URL/v1/api/user/auth/device/start?${ + queryString("app" to appName) + }", + ) + + suspend fun redeemDeviceAuthorization( + deviceCode: String, + ): DebridApiResponse> = + requestWithoutAuth( + method = "POST", + url = "$BASE_URL/v1/api/user/auth/device/token", + body = DebridApiJson.json.encodeToString(TorboxDeviceTokenRequestDto(deviceCode = deviceCode)), + contentType = "application/json", + ) + suspend fun validateApiKey(apiKey: String): Boolean = getUser(apiKey.trim()).status in 200..299 @@ -139,6 +159,29 @@ internal object TorboxApiClient { ) } + private suspend inline fun requestWithoutAuth( + method: String, + url: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ).toMap() + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + private fun authHeaders(apiKey: String): Map = mapOf("Authorization" to "Bearer $apiKey") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt index ff74b44d..909fd46a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -49,6 +49,27 @@ internal data class TorboxCheckCachedRequestDto( val hashes: List, ) +@Serializable +internal data class TorboxDeviceAuthorizationDto( + @SerialName("device_code") val deviceCode: String? = null, + val code: String? = null, + @SerialName("verification_url") val verificationUrl: String? = null, + @SerialName("friendly_verification_url") val friendlyVerificationUrl: String? = null, + val interval: Int? = null, + @SerialName("expires_at") val expiresAt: String? = null, +) + +@Serializable +internal data class TorboxDeviceTokenRequestDto( + @SerialName("device_code") val deviceCode: String, +) + +@Serializable +internal data class TorboxDeviceTokenDto( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, +) + @Serializable internal data class TorboxCachedItemDto( val name: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt index 7176188d..ac6aa352 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -5,6 +5,7 @@ data class DebridProvider( val displayName: String, val shortName: String, val visibleInUi: Boolean = true, + val authMethod: DebridProviderAuthMethod = DebridProviderAuthMethod.ApiKey, val capabilities: Set = emptySet(), ) @@ -19,6 +20,11 @@ enum class DebridProviderCapability { LocalTorrentResolve, } +enum class DebridProviderAuthMethod { + ApiKey, + DeviceCode, +} + object DebridProviders { const val TORBOX_ID = "torbox" const val REAL_DEBRID_ID = "realdebrid" @@ -27,6 +33,7 @@ object DebridProviders { id = TORBOX_ID, displayName = "Torbox", shortName = "TB", + authMethod = DebridProviderAuthMethod.DeviceCode, capabilities = setOf( DebridProviderCapability.ClientResolve, DebridProviderCapability.LocalTorrentCacheCheck, 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 b04c3538..b3bd1ac1 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 @@ -9,6 +9,11 @@ internal interface DebridProviderApi { suspend fun validateApiKey(apiKey: String): Boolean + suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? = null + + suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult = + DebridDeviceAuthorizationTokenResult.Unsupported + suspend fun resolveClientStream( stream: StreamItem, apiKey: String, @@ -29,6 +34,24 @@ internal object DebridProviderApis { } } +internal data class DebridDeviceAuthorization( + val providerId: String, + val deviceCode: String, + val userCode: String, + val verificationUrl: String, + val friendlyVerificationUrl: String, + val intervalSeconds: Int, + val expiresAt: String?, +) + +internal sealed interface DebridDeviceAuthorizationTokenResult { + data class Authorized(val accessToken: String) : DebridDeviceAuthorizationTokenResult + data object Pending : DebridDeviceAuthorizationTokenResult + data object Expired : DebridDeviceAuthorizationTokenResult + data object Unsupported : DebridDeviceAuthorizationTokenResult + data class Failed(val message: String?) : DebridDeviceAuthorizationTokenResult +} + private class TorboxDebridProviderApi( private val fileSelector: TorboxFileSelector = TorboxFileSelector(), ) : DebridProviderApi { @@ -37,6 +60,55 @@ private class TorboxDebridProviderApi( override suspend fun validateApiKey(apiKey: String): Boolean = TorboxApiClient.validateApiKey(apiKey) + override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? { + val response = TorboxApiClient.startDeviceAuthorization(appName = appName) + val data = response.body?.takeIf { response.isSuccessful && it.success != false }?.data + ?: return null + val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null + val userCode = data.code?.takeIf { it.isNotBlank() } ?: return null + val verificationUrl = data.verificationUrl?.takeIf { it.isNotBlank() } ?: return null + return DebridDeviceAuthorization( + providerId = provider.id, + deviceCode = deviceCode, + userCode = userCode, + verificationUrl = verificationUrl, + friendlyVerificationUrl = data.friendlyVerificationUrl?.takeIf { it.isNotBlank() } + ?: verificationUrl, + intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5, + expiresAt = data.expiresAt?.takeIf { it.isNotBlank() }, + ) + } + + override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult { + val normalized = deviceCode.trim() + if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) + val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized) + val envelope = response.body + val accessToken = envelope + ?.takeIf { response.isSuccessful && it.success != false } + ?.data + ?.accessToken + ?.takeIf { it.isNotBlank() } + if (accessToken != null) { + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) + .joinToString(" ") + .lowercase() + return when { + message.contains("pending") || message.contains("not authorized") -> + DebridDeviceAuthorizationTokenResult.Pending + message.contains("expired") -> + DebridDeviceAuthorizationTokenResult.Expired + response.status == 404 || response.status == 409 || response.status == 425 -> + DebridDeviceAuthorizationTokenResult.Pending + response.status == 410 -> + DebridDeviceAuthorizationTokenResult.Expired + else -> + DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) + } + } + override suspend fun resolveClientStream( stream: StreamItem, apiKey: String, 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 43633e1e..93db2c73 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 @@ -21,6 +21,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -30,6 +31,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,10 +40,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT import com.nuvio.app.features.debrid.DebridCredentialValidator +import com.nuvio.app.features.debrid.DebridDeviceAuthorization +import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviderApis +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 @@ -58,16 +68,30 @@ import com.nuvio.app.features.debrid.DebridStreamSortDirection import com.nuvio.app.features.debrid.DebridStreamSortKey import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear +import nuvio.composeapp.generated.resources.action_retry import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_save import nuvio.composeapp.generated.resources.action_saving import nuvio.composeapp.generated.resources.settings_debrid_add_key_first +import nuvio.composeapp.generated.resources.settings_debrid_connected +import nuvio.composeapp.generated.resources.settings_debrid_connect_provider +import nuvio.composeapp.generated.resources.settings_debrid_disconnect_provider +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_code_copied +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_connected +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_expired +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_failed +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_instructions +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_open +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_starting +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_waiting import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle import nuvio.composeapp.generated.resources.settings_debrid_dialog_title +import nuvio.composeapp.generated.resources.settings_debrid_disconnect import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice @@ -86,6 +110,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_name_template import nuvio.composeapp.generated.resources.settings_debrid_name_template_description import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_description +import nuvio.composeapp.generated.resources.settings_debrid_provider_device_description import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting import nuvio.composeapp.generated.resources.settings_debrid_section_providers @@ -127,8 +152,11 @@ internal fun LazyListScope.debridSettingsContent( } item { - var activeProviderId by rememberSaveable { mutableStateOf(null) } + var activeApiKeyProviderId by rememberSaveable { mutableStateOf(null) } + var activeDeviceAuthProviderId by rememberSaveable { mutableStateOf(null) } val providers = remember { DebridProviders.visible() } + val notSetLabel = stringResource(Res.string.settings_debrid_not_set) + val connectedLabel = stringResource(Res.string.settings_debrid_connected) SettingsSection( title = stringResource(Res.string.settings_debrid_section_providers), @@ -142,16 +170,42 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = provider.displayName, - description = stringResource(Res.string.settings_debrid_provider_description, provider.displayName), - value = maskDebridApiKey(settings.apiKeyFor(provider.id), stringResource(Res.string.settings_debrid_not_set)), + description = if (provider.authMethod == DebridProviderAuthMethod.DeviceCode) { + stringResource(Res.string.settings_debrid_provider_device_description, provider.displayName) + } else { + stringResource(Res.string.settings_debrid_provider_description, provider.displayName) + }, + value = providerCredentialStatus( + provider = provider, + credential = settings.apiKeyFor(provider.id), + notSetLabel = notSetLabel, + connectedLabel = connectedLabel, + ), enabled = true, - onClick = { activeProviderId = provider.id }, + onClick = { + when (provider.authMethod) { + DebridProviderAuthMethod.DeviceCode -> activeDeviceAuthProviderId = provider.id + DebridProviderAuthMethod.ApiKey -> activeApiKeyProviderId = provider.id + } + }, ) } } } - activeProviderId + activeDeviceAuthProviderId + ?.let(DebridProviders::byId) + ?.let { provider -> + DebridDeviceAuthDialog( + provider = provider, + currentValue = settings.apiKeyFor(provider.id), + onConnected = { token -> DebridSettingsRepository.setProviderApiKey(provider.id, token) }, + onDisconnect = { DebridSettingsRepository.setProviderApiKey(provider.id, "") }, + onDismiss = { activeDeviceAuthProviderId = null }, + ) + } + + activeApiKeyProviderId ?.let(DebridProviders::byId) ?.let { provider -> DebridApiKeyDialog( @@ -161,7 +215,7 @@ internal fun LazyListScope.debridSettingsContent( placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder, provider.displayName), currentValue = settings.apiKeyFor(provider.id), onSave = { apiKey -> DebridSettingsRepository.setProviderApiKey(provider.id, apiKey) }, - onDismiss = { activeProviderId = null }, + onDismiss = { activeApiKeyProviderId = null }, ) } } @@ -1182,6 +1236,205 @@ private enum class DebridStreamPicker { EXCLUDED_RELEASE_GROUPS, } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridDeviceAuthDialog( + provider: DebridProvider, + currentValue: String, + onConnected: (String) -> Unit, + onDisconnect: () -> Unit, + onDismiss: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + val isConnected = currentValue.isNotBlank() + var restartNonce by rememberSaveable(provider.id) { mutableStateOf(0) } + var session by remember(provider.id, restartNonce, isConnected) { mutableStateOf(null) } + var isStarting by remember(provider.id, restartNonce, isConnected) { mutableStateOf(!isConnected) } + var isPolling by remember(provider.id, restartNonce, isConnected) { mutableStateOf(false) } + var statusMessage by remember(provider.id, restartNonce, isConnected) { mutableStateOf(null) } + + val startingMessage = stringResource(Res.string.settings_debrid_device_auth_starting) + val waitingMessage = stringResource(Res.string.settings_debrid_device_auth_waiting) + val failedMessage = stringResource(Res.string.settings_debrid_device_auth_failed) + val expiredMessage = stringResource(Res.string.settings_debrid_device_auth_expired) + val codeCopiedMessage = stringResource(Res.string.settings_debrid_device_auth_code_copied) + + LaunchedEffect(provider.id, restartNonce, isConnected) { + if (isConnected) { + isStarting = false + isPolling = false + statusMessage = null + session = null + return@LaunchedEffect + } + isStarting = true + isPolling = false + statusMessage = null + session = runCatching { + DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") + }.getOrNull() + isStarting = false + statusMessage = if (session == null) failedMessage else waitingMessage + } + + LaunchedEffect(session?.deviceCode, restartNonce, isConnected) { + if (isConnected) return@LaunchedEffect + val activeSession = session ?: return@LaunchedEffect + while (true) { + delay(activeSession.intervalSeconds.coerceAtLeast(1) * 1_000L) + isPolling = true + val result = runCatching { + DebridProviderApis.apiFor(provider.id) + ?.redeemDeviceAuthorization(activeSession.deviceCode) + ?: DebridDeviceAuthorizationTokenResult.Unsupported + }.getOrElse { + DebridDeviceAuthorizationTokenResult.Failed(it.message) + } + isPolling = false + when (result) { + is DebridDeviceAuthorizationTokenResult.Authorized -> { + onConnected(result.accessToken) + onDismiss() + return@LaunchedEffect + } + + DebridDeviceAuthorizationTokenResult.Pending -> { + statusMessage = waitingMessage + } + + DebridDeviceAuthorizationTokenResult.Expired -> { + statusMessage = expiredMessage + return@LaunchedEffect + } + + is DebridDeviceAuthorizationTokenResult.Failed, + DebridDeviceAuthorizationTokenResult.Unsupported -> { + statusMessage = failedMessage + return@LaunchedEffect + } + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface( + title = stringResource( + if (isConnected) Res.string.settings_debrid_disconnect_provider else Res.string.settings_debrid_connect_provider, + provider.displayName, + ), + ) { + if (isConnected) { + Text( + text = stringResource(Res.string.settings_debrid_device_auth_connected, provider.displayName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (isStarting) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Text( + text = startingMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + session?.let { activeSession -> + Text( + text = stringResource(Res.string.settings_debrid_device_auth_instructions), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboardManager.setText(AnnotatedString(activeSession.userCode)) + statusMessage = codeCopiedMessage + }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = activeSession.userCode, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = activeSession.friendlyVerificationUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + statusMessage?.let { message -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isPolling) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + } + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = if (message == failedMessage || message == expiredMessage) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + if (isConnected) { + Button( + onClick = { + onDisconnect() + onDismiss() + }, + ) { + Text(stringResource(Res.string.settings_debrid_disconnect)) + } + } + if (!isConnected && !isStarting && session == null) { + TextButton(onClick = { restartNonce += 1 }) { + Text(stringResource(Res.string.action_retry)) + } + } + if (!isConnected) session?.let { activeSession -> + Button( + onClick = { + runCatching { uriHandler.openUri(activeSession.verificationUrl) } + .onFailure { statusMessage = failedMessage } + }, + enabled = !isStarting, + ) { + Text(stringResource(Res.string.settings_debrid_device_auth_open)) + } + } + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun DebridApiKeyDialog( @@ -1287,6 +1540,17 @@ private fun maskDebridApiKey(key: String, notSetLabel: String): String { return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" } +private fun providerCredentialStatus( + provider: DebridProvider, + credential: String, + notSetLabel: String, + connectedLabel: String, +): String = + when (provider.authMethod) { + DebridProviderAuthMethod.DeviceCode -> if (credential.isBlank()) notSetLabel else connectedLabel + DebridProviderAuthMethod.ApiKey -> maskDebridApiKey(credential, notSetLabel) + } + @Composable private fun DebridInfoRow( isTablet: Boolean, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt index a8499b58..2f0eccd2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -7,6 +7,7 @@ import kotlin.test.assertTrue class DebridProviderTest { @Test fun `torbox exposes local addon capabilities`() { + assertTrue(DebridProviders.Torbox.authMethod == DebridProviderAuthMethod.DeviceCode) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve)) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck)) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve)) @@ -14,6 +15,7 @@ class DebridProviderTest { @Test fun `real debrid stays hidden from local addon capability paths`() { + assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey) assertFalse(DebridProviders.RealDebrid.visibleInUi) assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve)) assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck))