From 9b4ba97d02a27fac4452f75427cbb79d311c28f4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:40:32 +0100 Subject: feat(app): add the Compose proxy screen Host, port, user name and password. An invalid host or port blocks the save, an emptied port is cleared, the proxy applies at once, and the screen is FLAG_SECURE while the saved password is on it. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/settings/ProxyScreen.kt | 197 +++++++++++++++++++++ .../passwordstore/ui/settings/ProxyViewModel.kt | 60 +++++++ app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 259 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt (limited to 'app/src') diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt new file mode 100644 index 00000000..b7116bb7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.hilt.navigation.compose.hiltViewModel +import app.passwordstore.R +import app.passwordstore.ui.compose.R as composeR +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.main.MainActivity + +/** Proxy host, port, user name and password; [hostError] and [portError] block the save. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxyScreen( + host: String, + port: String, + username: String, + password: String, + hostError: Boolean, + portError: Boolean, + onHostChange: (String) -> Unit, + onPortChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSave: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var revealed by remember { mutableStateOf(false) } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.activity_proxy_label)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + val field = Modifier.fillMaxWidth() + OutlinedTextField( + value = host, + onValueChange = onHostChange, + modifier = field, + label = { Text(stringResource(R.string.proxy_hostname)) }, + supportingText = + if (hostError) { + { Text(stringResource(R.string.invalid_proxy_url)) } + } else null, + isError = hostError, + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = port, + onValueChange = { onPortChange(it.filter(Char::isDigit)) }, + modifier = field, + label = { Text(stringResource(R.string.port)) }, + supportingText = + if (portError) { + { Text(stringResource(R.string.invalid_proxy_port)) } + } else null, + isError = portError, + singleLine = true, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = username, + onValueChange = onUsernameChange, + modifier = field, + label = { Text(stringResource(R.string.username)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + modifier = field, + label = { Text(stringResource(R.string.password)) }, + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) composeR.drawable.baseline_visibility_off_24 + else composeR.drawable.baseline_visibility_24 + ), + contentDescription = + stringResource( + if (revealed) R.string.entry_hide_password else R.string.entry_show_password + ), + ) + } + }, + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + ) + Button(onClick = onSave, modifier = field, enabled = !hostError && !portError) { + Text(stringResource(R.string.crypto_save)) + } + } + } +} + +/** + * The proxy screen. Holds FLAG_SECURE while shown: it shows the saved proxy password (masked, with + * a reveal toggle). + */ +@Composable +fun ProxyDestination( + activity: MainActivity, + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProxyViewModel = hiltViewModel(), +) { + DisposableEffect(activity) { + activity.holdSecure() + onDispose { activity.releaseSecure() } + } + ProxyScreen( + host = viewModel.host, + port = viewModel.port, + username = viewModel.username, + password = viewModel.password, + hostError = viewModel.hostError, + portError = viewModel.portError, + onHostChange = { viewModel.host = it }, + onPortChange = { viewModel.port = it }, + onUsernameChange = { viewModel.username = it }, + onPasswordChange = { viewModel.password = it }, + onSave = { + if (viewModel.save()) { + Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT).show() + onClose() + } + }, + onBack = onClose, + modifier = modifier, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt new file mode 100644 index 00000000..ccd275a6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.net.InetAddresses +import android.os.Build +import android.util.Patterns +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import app.passwordstore.util.proxy.ProxyUtils +import app.passwordstore.util.settings.GitSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** A numeric address or a web host name, as the old proxy screen accepted. */ +fun isProxyHost(text: String): Boolean { + val numeric = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) InetAddresses.isNumericAddress(text) + else @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() + return numeric || Patterns.WEB_URL.matcher(text).matches() +} + +/** + * The proxy form, prefilled from the stored proxy. The password lives here in memory only, never in + * saved state; it is stored in `KeystorePreferences` through [GitSettings]. + */ +@HiltViewModel +class ProxyViewModel +@Inject +constructor(private val gitSettings: GitSettings, private val proxyUtils: ProxyUtils) : + ViewModel() { + + var host by mutableStateOf(gitSettings.proxyHost.orEmpty()) + var port by mutableStateOf(gitSettings.proxyPort.takeIf { it != -1 }?.toString().orEmpty()) + var username by mutableStateOf(gitSettings.proxyUsername.orEmpty()) + var password by mutableStateOf(gitSettings.proxyPassword.orEmpty()) + + val hostError: Boolean + get() = host.isNotBlank() && !isProxyHost(host.trim()) + + val portError: Boolean + get() = proxyPort(port) == null + + /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */ + fun save(): Boolean { + if (hostError) return false + val portValue = proxyPort(port) ?: return false + gitSettings.proxyHost = host.trim().ifEmpty { null } + gitSettings.proxyPort = portValue + gitSettings.proxyUsername = username.trim().ifEmpty { null } + gitSettings.proxyPassword = password.ifEmpty { null } + proxyUtils.setDefaultProxy() + return true + } +} diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b9cf71fc..50566188 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -379,5 +379,6 @@ Impossibile eliminare tutto il contenuto della cartella dell\'archivio. Verrà chiesto di nuovo alla prossima operazione Git. + Inserisci una porta da 1 a 65535, oppure niente Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0224a72c..02f02e18 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,5 +414,6 @@ Could not delete everything in the store folder. It will be asked again at the next Git operation. + Enter a port from 1 to 65535, or nothing Delete the local store? Entries not pushed to the remote are lost. -- cgit v1.2.3