feat(settings): align relay and storage controls

This commit is contained in:
2026-07-23 19:03:19 +02:00
parent 7592d49a59
commit efb3c474d1
19 changed files with 326 additions and 107 deletions

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.</string>
<string name="relay_apply_failed">Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.</string>
<string name="relay_apply_restart_description">Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Relay-URLs</string>
<string name="relay_mode_automatic">Automatisch (empfohlen)</string>
<string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.</string>
<string name="relay_apply_failed">No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.</string>
<string name="relay_apply_restart_description">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URL de relés</string>
<string name="relay_mode_automatic">Automático (recomendado)</string>
<string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Arrêtez tous les transferts et partages actifs avant dappliquer les réglages réseau.</string>
<string name="relay_apply_failed">Impossible dappliquer ces réglages. Les réglages réseau précédents ont été restaurés.</string>
<string name="relay_apply_restart_description">Lapplication de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez dabord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_help">Ajoutez chaque URL de relais HTTPS 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.</string>
<string name="relay_custom_urls_label">URL des relais</string>
<string name="relay_mode_automatic">Automatique (recommandé)</string>
<string name="relay_mode_automatic_description">Utiliser linfrastructure de relais publique par défaut de VniDrop lorsquune connexion directe est indisponible.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.</string>
<string name="relay_apply_failed">Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.</string>
<string name="relay_apply_restart_description">Lapplicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</string>
<string name="relay_custom_urls_help">Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da unautorità di certificazione pubblicamente attendibile.</string>
<string name="relay_custom_urls_help">Aggiungi separatamente ogni URL relay HTTPS. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da unautorità di certificazione pubblicamente attendibile.</string>
<string name="relay_custom_urls_label">URL relay</string>
<string name="relay_mode_automatic">Automatica (consigliata)</string>
<string name="relay_mode_automatic_description">Usa linfrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.</string>
<string name="relay_apply_failed">Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.</string>
<string name="relay_apply_restart_description">Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Relay-URL\'s</string>
<string name="relay_mode_automatic">Automatisch (aanbevolen)</string>
<string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.</string>
<string name="relay_apply_failed">Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.</string>
<string name="relay_apply_restart_description">Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Adresy URL przekaźników</string>
<string name="relay_mode_automatic">Automatyczny (zalecany)</string>
<string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.</string>
<string name="relay_apply_failed">Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.</string>
<string name="relay_apply_restart_description">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URLs dos retransmissores</string>
<string name="relay_mode_automatic">Automático (recomendado)</string>
<string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Остановите все активные передачи и раздачи перед применением настроек сети.</string>
<string name="relay_apply_failed">Не удалось применить эти настройки. Предыдущие настройки сети восстановлены.</string>
<string name="relay_apply_restart_description">При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.</string>
<string name="relay_custom_urls_help">Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string>
<string name="relay_custom_urls_help">Добавляйте каждый HTTPS-адрес ретранслятора отдельно. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string>
<string name="relay_custom_urls_label">URL-адреса ретрансляторов</string>
<string name="relay_mode_automatic">Автоматически (рекомендуется)</string>
<string name="relay_mode_automatic_description">Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.</string>

View File

@@ -175,7 +175,7 @@
<string name="relay_apply_active_transfers">Stop all active transfers and shares before applying network settings.</string>
<string name="relay_apply_failed">Could not apply these settings. The previous network settings were restored.</string>
<string name="relay_apply_restart_description">Applying restarts VniDrops network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.</string>
<string name="relay_custom_urls_help">Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string>
<string name="relay_custom_urls_help">Add each HTTPS relay URL separately. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string>
<string name="relay_custom_urls_label">Relay URLs</string>
<string name="relay_mode_automatic">Automatic (recommended)</string>
<string name="relay_mode_automatic_description">Use VniDrops default public relay infrastructure when a direct connection is unavailable.</string>

View File

@@ -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) },
)
}
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_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) {
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) {

View File

@@ -19,13 +19,13 @@ data class RelaySettingsValidation(
fun validateRelaySettings(
mode: RelayMode,
urlsText: String,
relayUrls: List<String>,
retainedUrls: List<String> = 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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