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