aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java')
-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
2 files changed, 257 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
+ }
+}