aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-03 18:40:32 +0100
committerDanilo M. <danix@danix.xyz>2026-11-03 18:40:32 +0100
commit9b4ba97d02a27fac4452f75427cbb79d311c28f4 (patch)
tree41790a72e5a3d3c5996e65cdc0da4ad6b49cc45a /app/src
parentf58576017be0a280523a75a3580094ffac4ee7bd (diff)
downloadHardenedPass-9b4ba97d02a27fac4452f75427cbb79d311c28f4.tar.gz
HardenedPass-9b4ba97d02a27fac4452f75427cbb79d311c28f4.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt197
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt60
-rw-r--r--app/src/main/res/values-it/strings.xml1
-rw-r--r--app/src/main/res/values/strings.xml1
4 files changed, 259 insertions, 0 deletions
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. <danix@danix.xyz>
+ * 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. <danix@danix.xyz>
+ * 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 @@
<!-- Compose settings -->
<string name="store_delete_failed">Impossibile eliminare tutto il contenuto della cartella dell\'archivio.</string>
<string name="clear_saved_secret_message">Verrà chiesto di nuovo alla prossima operazione Git.</string>
+ <string name="invalid_proxy_port">Inserisci una porta da 1 a 65535, oppure niente</string>
<string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string>
</resources>
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 @@
<!-- Compose settings -->
<string name="store_delete_failed">Could not delete everything in the store folder.</string>
<string name="clear_saved_secret_message">It will be asked again at the next Git operation.</string>
+ <string name="invalid_proxy_port">Enter a port from 1 to 65535, or nothing</string>
<string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string>
</resources>