diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml
index e03f090..a4b86a1 100644
--- a/shared/src/commonMain/composeResources/values-de/strings.xml
+++ b/shared/src/commonMain/composeResources/values-de/strings.xml
@@ -175,7 +175,7 @@
Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.
Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.
Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.
- Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein.
+ Fügen Sie jede HTTPS-Relay-URL separat hinzu. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein.
Relay-URLs
Automatisch (empfohlen)
Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.
diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml
index 5c32bb0..826cf85 100644
--- a/shared/src/commonMain/composeResources/values-es/strings.xml
+++ b/shared/src/commonMain/composeResources/values-es/strings.xml
@@ -175,7 +175,7 @@
Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.
No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.
Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes.
- Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública.
+ Añada cada URL HTTPS de relé por separado. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública.
URL de relés
Automático (recomendado)
Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.
diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml
index cee1744..2d646c6 100644
--- a/shared/src/commonMain/composeResources/values-fr/strings.xml
+++ b/shared/src/commonMain/composeResources/values-fr/strings.xml
@@ -175,7 +175,7 @@
Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau.
Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés.
L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.
- Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement.
+ Ajoutez chaque URL de relais HTTPS séparément. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement.
URL des relais
Automatique (recommandé)
Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible.
diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml
index 25e6703..4d41143 100644
--- a/shared/src/commonMain/composeResources/values-it/strings.xml
+++ b/shared/src/commonMain/composeResources/values-it/strings.xml
@@ -175,7 +175,7 @@
Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.
Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.
L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.
- Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile.
+ Aggiungi separatamente ogni URL relay HTTPS. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile.
URL relay
Automatica (consigliata)
Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.
diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml
index a8d340a..5632874 100644
--- a/shared/src/commonMain/composeResources/values-nl/strings.xml
+++ b/shared/src/commonMain/composeResources/values-nl/strings.xml
@@ -175,7 +175,7 @@
Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.
Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.
Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.
- Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie.
+ Voeg elke HTTPS-relay-URL afzonderlijk toe. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie.
Relay-URL\'s
Automatisch (aanbevolen)
Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.
diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml
index abd0c02..5d5c8f4 100644
--- a/shared/src/commonMain/composeResources/values-pl/strings.xml
+++ b/shared/src/commonMain/composeResources/values-pl/strings.xml
@@ -175,7 +175,7 @@
Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.
Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.
Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.
- Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji.
+ Dodaj każdy adres URL HTTPS serwera przekaźnikowego osobno. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji.
Adresy URL przekaźników
Automatyczny (zalecany)
Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.
diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml
index dc43f18..26e59da 100644
--- a/shared/src/commonMain/composeResources/values-pt/strings.xml
+++ b/shared/src/commonMain/composeResources/values-pt/strings.xml
@@ -175,7 +175,7 @@
Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.
Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.
A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes.
- Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida.
+ Adicione cada URL HTTPS de retransmissor separadamente. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida.
URLs dos retransmissores
Automático (recomendado)
Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.
diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml
index d424d06..4fac253 100644
--- a/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -175,7 +175,7 @@
Остановите все активные передачи и раздачи перед применением настроек сети.
Не удалось применить эти настройки. Предыдущие настройки сети восстановлены.
При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.
- Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.
+ Добавляйте каждый HTTPS-адрес ретранслятора отдельно. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.
URL-адреса ретрансляторов
Автоматически (рекомендуется)
Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 507b498..5cc6635 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -175,7 +175,7 @@
Stop all active transfers and shares before applying network settings.
Could not apply these settings. The previous network settings were restored.
Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.
- Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.
+ Add each HTTPS relay URL separately. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.
Relay URLs
Automatic (recommended)
Use VniDrop’s default public relay infrastructure when a direct connection is unavailable.
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt
index 899b61c..12a87e6 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt
@@ -2,18 +2,26 @@ package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.core.usesCustomRelayUrls
-import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon
@@ -21,6 +29,7 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.approval_endpoint_id
+import vnidrop.shared.generated.resources.relay_add_url
import vnidrop.shared.generated.resources.relay_apply
import vnidrop.shared.generated.resources.relay_apply_active_transfers
import vnidrop.shared.generated.resources.relay_apply_failed
@@ -31,12 +40,13 @@ import vnidrop.shared.generated.resources.relay_custom_urls_label
import vnidrop.shared.generated.resources.relay_mode_automatic
import vnidrop.shared.generated.resources.relay_mode_automatic_description
import vnidrop.shared.generated.resources.relay_mode_custom
+import vnidrop.shared.generated.resources.relay_mode_custom_description
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback_description
-import vnidrop.shared.generated.resources.relay_mode_custom_description
import vnidrop.shared.generated.resources.relay_mode_local_only
import vnidrop.shared.generated.resources.relay_mode_local_only_description
import vnidrop.shared.generated.resources.relay_privacy_description
+import vnidrop.shared.generated.resources.relay_remove_url
import vnidrop.shared.generated.resources.relay_restore_failed
import vnidrop.shared.generated.resources.relay_strict_warning
import vnidrop.shared.generated.resources.relay_validation_duplicate_url
@@ -50,7 +60,9 @@ import vnidrop.shared.generated.resources.settings_network_title
internal fun NetworkSettings(
state: SettingsState,
onModeChanged: (RelayMode) -> Unit,
- onUrlsChanged: (String) -> Unit,
+ onUrlChanged: (Int, String) -> Unit,
+ onAddUrl: () -> Unit,
+ onRemoveUrl: (Int) -> Unit,
onApply: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
@@ -58,15 +70,6 @@ internal fun NetworkSettings(
val colors = LocalVniDropColors.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.settings_network_title), onBack, showBack)
- state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId ->
- SelectionContainer {
- Text(
- stringResource(Res.string.approval_endpoint_id, endpointId),
- color = colors.foregroundLighter,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- }
SettingsGroup {
RelayModeRow(
icon = AppIcon.Globe,
@@ -104,14 +107,52 @@ internal fun NetworkSettings(
onClick = { onModeChanged(RelayMode.LocalOnly) },
)
}
+ Text(
+ stringResource(Res.string.relay_privacy_description),
+ color = colors.foregroundLighter,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId ->
+ SelectionContainer {
+ Text(
+ stringResource(Res.string.approval_endpoint_id, endpointId),
+ color = colors.foregroundLighter,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
if (state.relayMode.usesCustomRelayUrls) {
- Field(
- value = state.relayUrlsText,
- onValueChange = onUrlsChanged,
- label = stringResource(Res.string.relay_custom_urls_label),
- minLines = 3,
- enabled = !state.isApplyingRelaySettings,
+ Text(
+ stringResource(Res.string.relay_custom_urls_label),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
)
+ SettingsGroup {
+ state.relayUrls.forEachIndexed { index, url ->
+ RelayUrlRow(
+ value = url,
+ error = relayUrlErrorText(state.relayInputError, index),
+ enabled = !state.isApplyingRelaySettings,
+ onValueChange = { onUrlChanged(index, it) },
+ onRemove = { onRemoveUrl(index) },
+ )
+ if (index < state.relayUrls.lastIndex) {
+ SettingsDivider(startPadding = 14.dp)
+ }
+ }
+ if (state.relayUrls.isNotEmpty()) {
+ SettingsDivider(startPadding = 14.dp)
+ }
+ TextButton(
+ onClick = onAddUrl,
+ enabled = state.relayUrls.size < MaximumRelayUrls && !state.isApplyingRelaySettings,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ ) {
+ PlatformIcon(AppIcon.Add, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.relay_add_url))
+ }
+ }
Text(
stringResource(Res.string.relay_custom_urls_help),
color = colors.foregroundLighter,
@@ -125,13 +166,8 @@ internal fun NetworkSettings(
fontWeight = FontWeight.Medium,
)
}
- Text(
- stringResource(Res.string.relay_privacy_description),
- color = colors.foregroundLighter,
- style = MaterialTheme.typography.bodySmall,
- )
}
- relayErrorText(state)?.let { error ->
+ relayGlobalErrorText(state)?.let { error ->
Text(
error,
color = colors.destructiveDefault,
@@ -189,7 +225,70 @@ private fun RelayModeRow(
}
@Composable
-private fun relayErrorText(state: SettingsState): String? {
+private fun RelayUrlRow(
+ value: String,
+ error: String?,
+ enabled: Boolean,
+ onValueChange: (String) -> Unit,
+ onRemove: () -> Unit,
+) {
+ val colors = LocalVniDropColors.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 14.dp, end = 6.dp, top = 10.dp, bottom = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier.weight(1f),
+ enabled = enabled,
+ singleLine = true,
+ isError = error != null,
+ placeholder = { Text("https://relay.example.com") },
+ supportingText = error?.let {
+ {
+ Text(it, color = colors.destructiveDefault)
+ }
+ },
+ shape = RoundedCornerShape(8.dp),
+ )
+ IconButton(onClick = onRemove, enabled = enabled) {
+ PlatformIcon(
+ AppIcon.Delete,
+ contentDescription = stringResource(Res.string.relay_remove_url),
+ tint = colors.destructiveDefault,
+ )
+ }
+ }
+}
+
+@Composable
+private fun relayUrlErrorText(error: RelaySettingsInputError?, index: Int): String? = when (error) {
+ is RelaySettingsInputError.HttpsRequired -> if (error.line == index + 1) {
+ stringResource(Res.string.relay_validation_https_required, error.line)
+ } else {
+ null
+ }
+ is RelaySettingsInputError.InvalidUrl -> if (error.line == index + 1) {
+ stringResource(Res.string.relay_validation_invalid_url, error.line)
+ } else {
+ null
+ }
+ is RelaySettingsInputError.DuplicateUrl -> if (error.line == index + 1) {
+ stringResource(Res.string.relay_validation_duplicate_url, error.line)
+ } else {
+ null
+ }
+ RelaySettingsInputError.MissingUrl,
+ is RelaySettingsInputError.TooManyUrls,
+ null,
+ -> null
+}
+
+@Composable
+private fun relayGlobalErrorText(state: SettingsState): String? {
if (state.hasActiveNetworkWork || state.relayApplyError == RelaySettingsApplyError.ActiveTransfers) {
return stringResource(Res.string.relay_apply_active_transfers)
}
@@ -200,18 +299,10 @@ private fun relayErrorText(state: SettingsState): String? {
Res.string.relay_validation_too_many_urls,
error.maximum,
)
- is RelaySettingsInputError.HttpsRequired -> stringResource(
- Res.string.relay_validation_https_required,
- error.line,
- )
- is RelaySettingsInputError.InvalidUrl -> stringResource(
- Res.string.relay_validation_invalid_url,
- error.line,
- )
- is RelaySettingsInputError.DuplicateUrl -> stringResource(
- Res.string.relay_validation_duplicate_url,
- error.line,
- )
+ is RelaySettingsInputError.HttpsRequired,
+ is RelaySettingsInputError.InvalidUrl,
+ is RelaySettingsInputError.DuplicateUrl,
+ -> null
}
}
return when (state.relayApplyError) {
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt
index 3f11add..c41e211 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt
@@ -19,13 +19,13 @@ data class RelaySettingsValidation(
fun validateRelaySettings(
mode: RelayMode,
- urlsText: String,
+ relayUrls: List,
retainedUrls: List = emptyList(),
): RelaySettingsValidation {
if (!mode.usesCustomRelayUrls) {
return RelaySettingsValidation(RelaySettings(mode, retainedUrls))
}
- val lines = urlsText.lineSequence()
+ val lines = relayUrls
.mapIndexedNotNull { index, raw -> raw.trim().takeIf(String::isNotEmpty)?.let { index + 1 to it } }
.toList()
if (lines.isEmpty()) return RelaySettingsValidation(error = RelaySettingsInputError.MissingUrl)
@@ -144,5 +144,5 @@ private fun isValidPortSuffix(suffix: String): Boolean {
}
private const val HttpsPrefix = "https://"
-private const val MaximumRelayUrls = 8
+internal const val MaximumRelayUrls = 8
private const val MaximumRelayUrlLength = 2_048
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
index 997559f..61835b5 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt
@@ -40,14 +40,6 @@ internal fun SettingsOverview(
fontWeight = FontWeight.Bold,
)
SettingsGroup {
- SettingsRow(
- icon = AppIcon.Globe,
- title = stringResource(Res.string.settings_network_title),
- value = relayModeLabel(state.savedRelaySettings.mode),
- selected = state.selectedSection == SettingsSection.Network,
- onClick = { onSectionSelected(SettingsSection.Network) },
- )
- SettingsDivider()
SettingsRow(
icon = AppIcon.User,
title = stringResource(Res.string.preferences_title),
@@ -78,7 +70,17 @@ internal fun SettingsOverview(
selected = state.selectedSection == SettingsSection.Storage,
onClick = { onSectionSelected(SettingsSection.Storage) },
)
- SettingsDivider()
+ }
+ SettingsGroup {
+ SettingsRow(
+ icon = AppIcon.Globe,
+ title = stringResource(Res.string.settings_network_title),
+ value = relayModeLabel(state.savedRelaySettings.mode),
+ selected = state.selectedSection == SettingsSection.Network,
+ onClick = { onSectionSelected(SettingsSection.Network) },
+ )
+ }
+ SettingsGroup {
SettingsRow(
icon = AppIcon.Info,
title = stringResource(Res.string.about_title),
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt
index fa27f16..e9ec600 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt
@@ -25,7 +25,9 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onUsernameChanged = viewModel::setUsername,
onThemeModeChanged = viewModel::setThemeMode,
onRelayModeChanged = viewModel::setRelayMode,
- onRelayUrlsChanged = viewModel::setRelayUrlsText,
+ onRelayUrlChanged = viewModel::setRelayUrl,
+ onAddRelayUrl = viewModel::addRelayUrl,
+ onRemoveRelayUrl = viewModel::removeRelayUrl,
onApplyRelaySettings = viewModel::applyRelaySettings,
onChooseFolder = viewModel::chooseReceiveFolder,
onResetFolder = viewModel::resetReceiveFolder,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt
index e779658..fb367a7 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt
@@ -32,7 +32,9 @@ fun SettingsScreen(
onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit = {},
onRelayModeChanged: (RelayMode) -> Unit = {},
- onRelayUrlsChanged: (String) -> Unit = {},
+ onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> },
+ onAddRelayUrl: () -> Unit = {},
+ onRemoveRelayUrl: (Int) -> Unit = {},
onApplyRelaySettings: () -> Unit = {},
) {
if (windowClass == WindowClass.Desktop) {
@@ -65,7 +67,9 @@ fun SettingsScreen(
onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers,
onRelayModeChanged = onRelayModeChanged,
- onRelayUrlsChanged = onRelayUrlsChanged,
+ onRelayUrlChanged = onRelayUrlChanged,
+ onAddRelayUrl = onAddRelayUrl,
+ onRemoveRelayUrl = onRemoveRelayUrl,
onApplyRelaySettings = onApplyRelaySettings,
)
}
@@ -102,7 +106,9 @@ fun SettingsScreen(
onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers,
onRelayModeChanged = onRelayModeChanged,
- onRelayUrlsChanged = onRelayUrlsChanged,
+ onRelayUrlChanged = onRelayUrlChanged,
+ onAddRelayUrl = onAddRelayUrl,
+ onRemoveRelayUrl = onRemoveRelayUrl,
onApplyRelaySettings = onApplyRelaySettings,
)
}
@@ -131,7 +137,9 @@ private fun SettingsSectionContent(
onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit,
onRelayModeChanged: (RelayMode) -> Unit,
- onRelayUrlsChanged: (String) -> Unit,
+ onRelayUrlChanged: (Int, String) -> Unit,
+ onAddRelayUrl: () -> Unit,
+ onRemoveRelayUrl: (Int) -> Unit,
onApplyRelaySettings: () -> Unit,
) {
when (section) {
@@ -141,7 +149,9 @@ private fun SettingsSectionContent(
SettingsSection.Network -> NetworkSettings(
state = state,
onModeChanged = onRelayModeChanged,
- onUrlsChanged = onRelayUrlsChanged,
+ onUrlChanged = onRelayUrlChanged,
+ onAddUrl = onAddRelayUrl,
+ onRemoveUrl = onRemoveRelayUrl,
onApply = onApplyRelaySettings,
onBack = onBack,
showBack = showBack,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
index 15f1e63..48a96eb 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt
@@ -89,7 +89,7 @@ data class SettingsState(
val themeMode: ThemeMode = ThemeMode.System,
val savedRelaySettings: RelaySettings = RelaySettings(),
val relayMode: RelayMode = RelayMode.Automatic,
- val relayUrlsText: String = "",
+ val relayUrls: List = emptyList(),
val relayInputError: RelaySettingsInputError? = null,
val relayApplyError: RelaySettingsApplyError? = null,
val isApplyingRelaySettings: Boolean = false,
@@ -114,7 +114,7 @@ data class SettingsState(
) {
val hasRelaySettingsChanges: Boolean
get() = relayMode != savedRelaySettings.mode ||
- (relayMode.usesCustomRelayUrls && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n"))
+ (relayMode.usesCustomRelayUrls && relayUrls != savedRelaySettings.relayUrls)
}
sealed interface SettingsEffect {
@@ -162,10 +162,10 @@ class SettingsViewModel(
diagnosticsEnabled = preferences.diagnosticsEnabled,
savedRelaySettings = preferences.relaySettings,
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
- relayUrlsText = if (hasLocalRelayDraft) {
- current.relayUrlsText
+ relayUrls = if (hasLocalRelayDraft) {
+ current.relayUrls
} else {
- preferences.relaySettings.relayUrls.joinToString("\n")
+ preferences.relaySettings.relayUrls
},
)
}
@@ -269,6 +269,7 @@ class SettingsViewModel(
_state.update {
it.copy(
relayMode = mode,
+ relayUrls = if (mode.usesCustomRelayUrls && it.relayUrls.isEmpty()) listOf("") else it.relayUrls,
relayInputError = null,
relayApplyError = null,
)
@@ -276,11 +277,39 @@ class SettingsViewModel(
hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
}
- fun setRelayUrlsText(value: String) {
+ fun setRelayUrl(index: Int, value: String) {
+ if (index !in _state.value.relayUrls.indices) return
hasLocalRelayDraft = true
_state.update {
it.copy(
- relayUrlsText = value,
+ relayUrls = it.relayUrls.toMutableList().apply { this[index] = value },
+ relayInputError = null,
+ relayApplyError = null,
+ )
+ }
+ hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
+ }
+
+ fun addRelayUrl() {
+ if (_state.value.relayUrls.size >= MaximumRelayUrls) return
+ hasLocalRelayDraft = true
+ _state.update {
+ it.copy(
+ relayUrls = it.relayUrls + "",
+ relayInputError = null,
+ relayApplyError = null,
+ )
+ }
+ hasLocalRelayDraft = _state.value.hasRelaySettingsChanges
+ }
+
+ fun removeRelayUrl(index: Int) {
+ if (index !in _state.value.relayUrls.indices) return
+ hasLocalRelayDraft = true
+ _state.update {
+ val remaining = it.relayUrls.toMutableList().apply { removeAt(index) }
+ it.copy(
+ relayUrls = remaining.ifEmpty { listOf("") },
relayInputError = null,
relayApplyError = null,
)
@@ -293,7 +322,7 @@ class SettingsViewModel(
if (snapshot.isApplyingRelaySettings || !snapshot.hasRelaySettingsChanges) return
val validation = validateRelaySettings(
mode = snapshot.relayMode,
- urlsText = snapshot.relayUrlsText,
+ relayUrls = snapshot.relayUrls,
retainedUrls = snapshot.savedRelaySettings.relayUrls,
)
val desired = validation.settings
@@ -349,7 +378,7 @@ class SettingsViewModel(
it.copy(
savedRelaySettings = desired,
relayMode = desired.mode,
- relayUrlsText = desired.relayUrls.joinToString("\n"),
+ relayUrls = desired.relayUrls,
isApplyingRelaySettings = false,
relayInputError = null,
relayApplyError = null,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt
index f5c4c75..3dd7d67 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt
@@ -2,14 +2,21 @@ package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
@@ -37,27 +44,26 @@ internal fun StorageSettings(
val storage = state.storage
if (storage == null || state.isCalculatingStorage) {
SettingsGroup {
- SettingsRow(
- icon = AppIcon.Storage,
+ StorageRow(
title = stringResource(Res.string.storage_calculating),
- trailing = { CircularProgressIndicator() },
+ trailing = { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) },
)
}
} else {
SettingsGroup {
- StorageRow(AppIcon.TransferData, stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes)
- SettingsDivider()
- StorageRow(AppIcon.Storage, stringResource(Res.string.storage_app_data), storage.appDataBytes)
- SettingsDivider()
- StorageRow(AppIcon.Temporary, stringResource(Res.string.storage_temporary), storage.temporaryBytes)
- SettingsDivider()
- SettingsRow(
- icon = AppIcon.Folder,
- title = stringResource(Res.string.storage_received_files),
- value = formatBytes(storage.receivedBytes),
+ StorageRow(stringResource(Res.string.storage_received_files), storage.receivedBytes)
+ SettingsDivider(startPadding = 16.dp)
+ StorageRow(stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes)
+ SettingsDivider(startPadding = 16.dp)
+ StorageRow(stringResource(Res.string.storage_app_data), storage.appDataBytes)
+ SettingsDivider(startPadding = 16.dp)
+ StorageRow(stringResource(Res.string.storage_temporary), storage.temporaryBytes)
+ SettingsDivider(startPadding = 16.dp)
+ StorageRow(
+ title = stringResource(Res.string.storage_total),
+ bytes = storage.deviceImpactBytes,
+ emphasized = true,
)
- SettingsDivider()
- StorageRow(AppIcon.TotalStorage, stringResource(Res.string.storage_total), storage.deviceImpactBytes)
}
}
Button(
@@ -76,6 +82,33 @@ internal fun StorageSettings(
}
@Composable
-private fun StorageRow(icon: AppIcon, title: String, bytes: ULong) {
- SettingsRow(icon = icon, title = title, value = formatBytes(bytes))
+private fun StorageRow(
+ title: String,
+ bytes: ULong? = null,
+ emphasized: Boolean = false,
+ trailing: @Composable (() -> Unit)? = null,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 52.dp)
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ title,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal,
+ )
+ if (bytes != null) {
+ Text(
+ formatBytes(bytes),
+ color = LocalVniDropColors.current.foregroundLighter,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal,
+ )
+ }
+ trailing?.invoke()
+ }
}
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
index 25e47e6..65d393a 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
@@ -28,6 +28,7 @@ import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.RelaySettingsApplyError
+import com.vnidrop.app.feature.settings.RelaySettingsInputError
import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.AppPreferences
@@ -145,7 +146,9 @@ class ViewModelsTest {
advanceUntilIdle()
viewModel.setRelayMode(RelayMode.StrictCustom)
- viewModel.setRelayUrlsText(" HTTPS://Relay.Example.com/ \nhttps://backup.example.com:443")
+ viewModel.setRelayUrl(0, " HTTPS://Relay.Example.com/ ")
+ viewModel.addRelayUrl()
+ viewModel.setRelayUrl(1, "https://backup.example.com:443")
viewModel.applyRelaySettings()
advanceUntilIdle()
@@ -159,6 +162,37 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.hasRelaySettingsChanges)
}
+ @Test
+ fun settingsRejectsInvalidRelayUrlWithoutEnteringLoadingState() = runTest {
+ Dispatchers.setMain(StandardTestDispatcher(testScheduler))
+ val core = FakeCoreGateway()
+ val viewModel = settingsViewModel(repository = core)
+ advanceUntilIdle()
+
+ viewModel.setRelayMode(RelayMode.StrictCustom)
+ viewModel.setRelayUrl(0, "relay.example.com")
+ viewModel.applyRelaySettings()
+
+ assertFalse(viewModel.state.value.isApplyingRelaySettings)
+ assertEquals(RelaySettingsInputError.HttpsRequired(1), viewModel.state.value.relayInputError)
+ assertEquals(emptyList(), core.initializedRelaySettings)
+ }
+
+ @Test
+ fun settingsEditsCustomRelaysAsIndividualRows() = runTest {
+ Dispatchers.setMain(StandardTestDispatcher(testScheduler))
+ val viewModel = settingsViewModel()
+ advanceUntilIdle()
+
+ viewModel.setRelayMode(RelayMode.StrictCustom)
+ viewModel.setRelayUrl(0, "https://first.example.com")
+ viewModel.addRelayUrl()
+ viewModel.setRelayUrl(1, "https://second.example.com")
+ viewModel.removeRelayUrl(0)
+
+ assertEquals(listOf("https://second.example.com"), viewModel.state.value.relayUrls)
+ }
+
@Test
fun settingsAppliesCustomFallbackAndLocalOnlyWhileRetainingRelayDrafts() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -168,7 +202,7 @@ class ViewModelsTest {
advanceUntilIdle()
viewModel.setRelayMode(RelayMode.CustomWithDirectFallback)
- viewModel.setRelayUrlsText("https://relay.example.com")
+ viewModel.setRelayUrl(0, "https://relay.example.com")
viewModel.applyRelaySettings()
advanceUntilIdle()
viewModel.setRelayMode(RelayMode.LocalOnly)
@@ -196,7 +230,7 @@ class ViewModelsTest {
advanceUntilIdle()
viewModel.setRelayMode(RelayMode.StrictCustom)
- viewModel.setRelayUrlsText("https://relay.example.com")
+ viewModel.setRelayUrl(0, "https://relay.example.com")
viewModel.applyRelaySettings()
viewModel.applyRelaySettings()
assertTrue(viewModel.state.value.isApplyingRelaySettings)
@@ -216,7 +250,7 @@ class ViewModelsTest {
assertEquals("endpoint", viewModel.state.value.endpointId)
viewModel.setRelayMode(RelayMode.StrictCustom)
- viewModel.setRelayUrlsText("https://relay.example.com")
+ viewModel.setRelayUrl(0, "https://relay.example.com")
viewModel.applyRelaySettings()
advanceUntilIdle()
@@ -238,7 +272,7 @@ class ViewModelsTest {
advanceUntilIdle()
viewModel.setRelayMode(RelayMode.StrictCustom)
- viewModel.setRelayUrlsText("https://relay.example.com")
+ viewModel.setRelayUrl(0, "https://relay.example.com")
viewModel.applyRelaySettings()
advanceUntilIdle()
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt
index 639407e..cac78fb 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt
@@ -10,7 +10,7 @@ class RelaySettingsValidationTest {
fun customFallbackUsesTheSameValidatedRelayList() {
val result = validateRelaySettings(
mode = RelayMode.CustomWithDirectFallback,
- urlsText = "https://relay.example.com/",
+ relayUrls = listOf("https://relay.example.com/"),
)
assertEquals(
@@ -28,11 +28,11 @@ class RelaySettingsValidationTest {
assertEquals(
RelaySettings(RelayMode.Automatic, retained),
- validateRelaySettings(RelayMode.Automatic, "invalid", retained).settings,
+ validateRelaySettings(RelayMode.Automatic, listOf("invalid"), retained).settings,
)
assertEquals(
RelaySettings(RelayMode.LocalOnly, retained),
- validateRelaySettings(RelayMode.LocalOnly, "invalid", retained).settings,
+ validateRelaySettings(RelayMode.LocalOnly, listOf("invalid"), retained).settings,
)
}
@@ -40,7 +40,7 @@ class RelaySettingsValidationTest {
fun customRelayUrlsAreNormalized() {
val result = validateRelaySettings(
mode = RelayMode.StrictCustom,
- urlsText = " HTTPS://Relay.Example.com/ \nhttps://[2001:DB8::1]:443",
+ relayUrls = listOf(" HTTPS://Relay.Example.com/ ", "https://[2001:DB8::1]:443"),
)
assertEquals(
@@ -57,11 +57,11 @@ class RelaySettingsValidationTest {
fun customRelayUrlsRequireHttpsAndRootPath() {
assertEquals(
RelaySettingsInputError.HttpsRequired(1),
- validateRelaySettings(RelayMode.StrictCustom, "http://relay.example.com").error,
+ validateRelaySettings(RelayMode.StrictCustom, listOf("http://relay.example.com")).error,
)
assertEquals(
RelaySettingsInputError.InvalidUrl(1),
- validateRelaySettings(RelayMode.StrictCustom, "https://relay.example.com/custom").error,
+ validateRelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com/custom")).error,
)
}
@@ -69,7 +69,7 @@ class RelaySettingsValidationTest {
fun duplicateNormalizedRelayUrlsAreRejected() {
val result = validateRelaySettings(
RelayMode.StrictCustom,
- "https://relay.example.com\nHTTPS://RELAY.EXAMPLE.COM:443/",
+ listOf("https://relay.example.com", "HTTPS://RELAY.EXAMPLE.COM:443/"),
)
assertEquals(RelaySettingsInputError.DuplicateUrl(2), result.error)
@@ -79,7 +79,7 @@ class RelaySettingsValidationTest {
fun structurallyValidIpv6RelayUrlsAreAccepted() {
val result = validateRelaySettings(
RelayMode.StrictCustom,
- "https://[::1]:443\nhttps://[2001:db8::1]\nhttps://[::ffff:192.0.2.1]",
+ listOf("https://[::1]:443", "https://[2001:db8::1]", "https://[::ffff:192.0.2.1]"),
)
assertEquals(
@@ -105,7 +105,7 @@ class RelaySettingsValidationTest {
).forEach { url ->
assertEquals(
RelaySettingsInputError.InvalidUrl(1),
- validateRelaySettings(RelayMode.StrictCustom, url).error,
+ validateRelaySettings(RelayMode.StrictCustom, listOf(url)).error,
url,
)
}
diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt
index 75c35dc..2405ea3 100644
--- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt
+++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt
@@ -136,8 +136,25 @@ class FoundationComposeTest {
onBugContactChanged = {},
onBugIncludeLogsChanged = {},
onSubmitBugReport = {},
- onRelayModeChanged = { state.value = state.value.copy(relayMode = it) },
- onRelayUrlsChanged = { state.value = state.value.copy(relayUrlsText = it) },
+ onRelayModeChanged = {
+ state.value = state.value.copy(
+ relayMode = it,
+ relayUrls = state.value.relayUrls.ifEmpty { listOf("") },
+ )
+ },
+ onRelayUrlChanged = { index, value ->
+ state.value = state.value.copy(
+ relayUrls = state.value.relayUrls.toMutableList().apply { this[index] = value },
+ )
+ },
+ onAddRelayUrl = {
+ state.value = state.value.copy(relayUrls = state.value.relayUrls + "")
+ },
+ onRemoveRelayUrl = { index ->
+ state.value = state.value.copy(
+ relayUrls = state.value.relayUrls.toMutableList().apply { removeAt(index) }.ifEmpty { listOf("") },
+ )
+ },
onApplyRelaySettings = { applied = true },
)
}
@@ -150,6 +167,7 @@ class FoundationComposeTest {
"Strict custom mode will not start unless at least one configured relay is reachable. " +
"VniDrop never uses public relays or public discovery in this mode.",
).assertIsDisplayed()
+ onNodeWithText("Add relay server").assertIsDisplayed()
onNodeWithText("Apply network settings").performClick()
runOnIdle { assertTrue(applied) }
}