android: import preview/error sheet and profile form as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:00:09 +07:00
1 parent 200697216d
commit fecef1135c
6 files changed
+697 -477

No files matched your search

@@ -179,7 +179,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
ImportScreenKt.installImportScreen(this, mUi,
this::saveProfile, this::finish,
this::importPkcs12, this::chooseUserCertificate);
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
Intent intent = getIntent();
String action = intent.getAction();
@@ -246,13 +246,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
mImportPKCS12.launch(intent);
}
private Uri mLastUri;
private void loadProfile(Uri uri)
{
mUi.setLoading(true);
/* shown in the preview: "link" for downloads, "file" otherwise */
mUi.setSource(uri.getScheme());
mUi.setSourceUri(uri.toString());
mUi.setLoadError(null);
mLastUri = uri;
Bundle args = new Bundle();
args.putParcelable(PROFILE_URI, uri);
LoaderManager.getInstance(this).initLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
LoaderManager.getInstance(this).restartLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
}
/** «Try again» after a failed download. */
private void retryLoad()
{
if (mLastUri != null)
{
loadProfile(mLastUri);
}
}
public void handleProfile(ProfileLoadResult data)
@@ -300,15 +316,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
e.printStackTrace();
}
}
if (error != null)
{
Toast.makeText(this, getString(R.string.profile_import_failed_detail, error), Toast.LENGTH_LONG).show();
}
else
{
Toast.makeText(this, R.string.profile_import_failed, Toast.LENGTH_LONG).show();
}
finish();
/* the sheet shows the failure with the likely reasons and a retry */
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
return;
}
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
@@ -666,6 +675,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
if (mUi.getConnectNow())
{ /* «Connect right away» in the preview */
Intent start = new Intent(this, VpnProfileControlActivity.class);
start.setAction(VpnProfileControlActivity.START_PROFILE);
start.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, mProfile.getUUID().toString());
startActivity(start);
}
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
finish();
@@ -701,6 +717,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
*/
private void updateProfileData()
{
/* the name can be changed in the preview */
String name = mUi.getName().trim();
if (!name.isEmpty())
{
mProfile.setName(name);
}
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
{
mProfile.setUsername(mUi.getUsername().trim());
@@ -11,53 +11,59 @@
package org.strongswan.android.ui.compose
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.VpnKey
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
/**
* Observable UI state of the profile import screen. Owned by the (Java)
@@ -89,21 +95,28 @@ class ImportUiModel
var remoteCertPrimary by mutableStateOf<String?>(null)
var remoteCertSecondary by mutableStateOf<String?>(null)
var hideImport by mutableStateOf(false)
/** uri scheme of the profile source (https, content, file) */
var source by mutableStateOf<String?>(null)
var sourceUri by mutableStateOf<String?>(null)
/** set when the profile could not be read; the sheet shows it with a retry */
var loadError by mutableStateOf<String?>(null)
/** «Connect right away» after adding */
var connectNow by mutableStateOf(true)
}
/** Renders the import UI as a bottom sheet in the transparent import activity. */
@OptIn(ExperimentalMaterial3Api::class)
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
fun ComponentActivity.installImportScreen(
model: ImportUiModel,
onImport: Runnable,
onClose: Runnable,
onImportPkcs12: Runnable,
onSelectUserCert: Runnable,
onRetry: Runnable,
)
{
setContent {
ShxTheme {
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
ModalBottomSheet(
onDismissRequest = onClose::run,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.background,
)
ShxSheet(onDismiss = onClose::run)
{
ImportScreen(
model = model,
@@ -111,13 +124,13 @@ fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnab
onClose = onClose::run,
onImportPkcs12 = onImportPkcs12::run,
onSelectUserCert = onSelectUserCert::run,
onRetry = onRetry::run,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportScreen(
model: ImportUiModel,
@@ -125,221 +138,241 @@ fun ImportScreen(
onClose: () -> Unit,
onImportPkcs12: () -> Unit,
onSelectUserCert: () -> Unit,
onRetry: () -> Unit,
)
{
Column(Modifier.fillMaxSize())
val c = LocalShxColors.current
val context = LocalContext.current
when
{
TopAppBar(
title = { Text(stringResource(R.string.profile_import), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(android.R.string.cancel))
}
},
actions = {
if (model.showForm && !model.hideImport)
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
{
TextButton(onClick = onImport) { Text(stringResource(R.string.profile_edit_import)) }
/* F_ErrImport */
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(c.errorContainer))
{
Icon(ShxIcons.Alert, contentDescription = null, tint = c.error, modifier = Modifier.size(26.dp))
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
stringResource(if (model.source?.startsWith("http") == true) R.string.import_failed_download else R.string.import_failed_read),
style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text,
)
if (model.loading)
{
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
LoadingIndicator()
model.sourceUri?.let {
Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
return
}
}
val shape = RoundedCornerShape(22.dp)
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg)
.padding(bottom = ShxSpacing.xl),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
)
{
if (model.existsWarning != null)
Text(stringResource(R.string.import_reasons).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
val reasons = if (model.source?.startsWith("http") == true) listOf(
R.string.import_reason_blocked to R.string.import_reason_blocked_d,
R.string.import_reason_offline to R.string.import_reason_offline_d,
R.string.import_reason_tls to R.string.import_reason_tls_d,
) else listOf(R.string.import_reason_format to R.string.import_reason_format_d)
reasons.forEach { (t, d) ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ImportBanner(
text = model.existsWarning ?: "",
container = MaterialTheme.colorScheme.errorContainer,
content = MaterialTheme.colorScheme.onErrorContainer,
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(t)) }
append(" — ")
withStyle(SpanStyle(color = c.textSub)) { append(stringResource(d)) }
},
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
)
}
ShxCard(modifier = Modifier.padding(top = ShxSpacing.md))
{
ImportInfoRow(Icons.Rounded.Language, stringResource(R.string.profile_name_label_simple), model.name)
ImportInfoRow(Icons.Rounded.Dns, stringResource(R.string.profile_gateway_label), model.gateway)
ImportInfoRow(Icons.Rounded.VpnKey, stringResource(R.string.profile_vpn_type_label), model.vpnType, divider = false)
}
(model.loadError ?: model.existsWarning)?.let {
Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
ShxNote(stringResource(R.string.import_ask_admin))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (model.source?.startsWith("http") == true)
{
ShxButton(stringResource(R.string.retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
}
ShxButton(stringResource(R.string.add_close), onClose, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
{
Text(
stringResource(R.string.import_support_details),
style = ShxType.chip, color = c.accentText,
modifier = Modifier.clip(RoundedCornerShape(12.dp))
.clickable(role = Role.Button) { context.startActivity(Intent(context, LogActivity::class.java)) }.padding(8.dp),
)
}
}
model.loading ->
{
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp))
{
LoadingIndicator(Modifier.size(48.dp))
Text(stringResource(R.string.import_reading), style = ShxType.body, color = c.textSub)
}
}
else ->
{
/* F_ImportPreview */
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accentContainer))
{
Text(model.name.filter { it.isLetterOrDigit() }.take(2).uppercase().ifEmpty { "VPN" }, style = ShxType.cardHeading.copy(fontSize = 18.sp), color = c.onAccentContainer)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.import_add_title), style = ShxType.sheetTitle, color = c.text)
Text(stringResource(R.string.import_read_ok), style = ShxType.sub, color = c.textSub)
}
}
val shape = RoundedCornerShape(24.dp)
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
{
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(stringResource(R.string.import_name), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
BasicTextField(
value = model.name,
onValueChange = { model.name = it },
singleLine = true,
textStyle = ShxType.rowTitle.copy(color = c.text),
cursorBrush = SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
val rows = buildList {
add(stringResource(R.string.import_server) to (model.gateway to c.text))
add(stringResource(R.string.import_type) to (model.vpnType to c.text))
add(stringResource(R.string.import_login) to ((when
{
model.requireUserCert && model.requireUserPass -> stringResource(R.string.import_login_both)
model.requireUserCert -> stringResource(R.string.import_login_cert)
else -> stringResource(R.string.import_login_password)
}) to c.text))
add(stringResource(R.string.import_source) to (stringResource(if (model.source?.startsWith("http") == true) R.string.import_source_link else R.string.import_source_file) to c.text))
if (model.showRemoteCert)
{
add(stringResource(R.string.import_ca) to (stringResource(R.string.import_ca_included) to c.onSuccess))
}
}
rows.forEach { (k, v) ->
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 16.dp))
{
Text(k, style = ShxType.body, color = c.textSoft, maxLines = 1, softWrap = false)
Text(
v.first, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = v.second, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
)
}
}
}
if (model.requireUserPass)
{
Text(
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column(Modifier.padding(ShxSpacing.lg)) {
OutlinedTextField(
value = model.username,
onValueChange = { model.username = it },
label = { Text(stringResource(R.string.profile_username_label)) },
singleLine = true,
enabled = model.usernameEditable,
isError = model.usernameError,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(ShxSpacing.md))
OutlinedTextField(
value = model.password,
onValueChange = { model.password = it },
label = { Text(stringResource(R.string.profile_password_label)) },
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
singleLine = true,
enabled = model.passwordEditable,
modifier = Modifier.fillMaxWidth(),
)
}
}
ImportField(stringResource(R.string.profile_username_label).trimEnd(':'), model.username, model.usernameEditable, model.usernameError) { model.username = it }
ImportField(stringResource(R.string.profile_password_label).trimEnd(':'), model.password, model.passwordEditable, false, secret = true) { model.password = it }
if (model.sharedSecretWarning)
{
ImportBanner(
text = stringResource(R.string.profile_import_shared_secret),
container = MaterialTheme.colorScheme.surfaceContainerHigh,
content = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = ShxSpacing.sm),
)
ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL)
}
}
if (model.requireUserCert)
{
Text(
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column {
val certShape = RoundedCornerShape(24.dp)
Column(Modifier.fillMaxWidth().clip(certShape).background(c.card).border(1.dp, if (model.userCertError) c.error else c.cardBorder, certShape))
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSelectUserCert)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
) {
Column(Modifier.weight(1f)) {
Text(
model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label),
style = MaterialTheme.typography.bodyLarge,
color = if (model.userCertError) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.dp),
)
Text(
model.userCertSubject
?: stringResource(R.string.profile_user_select_certificate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (model.userCertLoading)
{
LoadingIndicator(Modifier.size(18.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (model.userCertError) c.error else c.text)
Text(model.userCertSubject ?: stringResource(R.string.profile_user_select_certificate), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
if (model.userCertLoading) LoadingIndicator(Modifier.size(20.dp))
else Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
if (model.showImportUserCert)
{
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = ShxSpacing.sm, bottom = ShxSpacing.sm)) {
Icon(Icons.Rounded.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.profile_cert_import))
}
}
}
}
}
if (model.showRemoteCert)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
Text(
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false)
}
}
}
}
}
@Composable
private fun ImportBanner(
text: String,
container: androidx.compose.ui.graphics.Color,
content: androidx.compose.ui.graphics.Color,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = container,
contentColor = content,
modifier = modifier
.fillMaxWidth()
.padding(top = ShxSpacing.sm),
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
stringResource(R.string.profile_cert_import), style = ShxType.chip, color = c.accentText,
modifier = Modifier.fillMaxWidth().clickable(onClick = onImportPkcs12).padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
@Composable
private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true)
{
Column {
}
}
val toggleShape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(toggleShape).background(c.card).border(1.dp, c.cardBorder, toggleShape).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
)
Column(Modifier.padding(start = ShxSpacing.lg)) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}
if (divider)
{
androidx.compose.material3.HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(start = 52.dp),
Text(stringResource(R.string.import_connect_now), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
ShxSwitch(checked = model.connectNow, onCheckedChange = { model.connectNow = it })
}
if (model.existsWarning != null)
{
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.import_exists_title)) }
append(" ")
append(stringResource(R.string.import_exists_text))
},
style = ShxType.note, color = c.textTonal,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(android.R.string.cancel), onClose, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(
stringResource(if (model.existsWarning != null) R.string.import_update_server else R.string.import_add_server),
onImport, modifier = Modifier.weight(2f), enabled = !model.hideImport,
)
}
}
}
}
/** Field of the manual/import forms: white box, radius 16, label 12/700, value 16/600. */
@Composable
private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(16.dp)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(if (error) 2.dp else 1.dp, if (error) c.error else c.cardBorder, shape).padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (error) c.error else c.textSub)
BasicTextField(
value = value,
onValueChange = onChange,
enabled = editable,
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 16.sp, color = if (editable) c.text else c.textSub),
cursorBrush = SolidColor(c.accent),
visualTransformation = if (secret) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -86,6 +86,27 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnType
@@ -282,6 +303,9 @@ fun ProfileFormState.isValid(): Boolean
return true
}
/** The three sign-in variants of the mockup tabs; the rarer types stay in «More». */
private val LOGIN_TYPES = listOf(VpnType.IKEV2_EAP, VpnType.IKEV2_CERT, VpnType.IKEV2_CERT_EAP)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileFormScreen(
@@ -295,6 +319,7 @@ fun ProfileFormScreen(
)
{
val context = LocalContext.current
val c = LocalShxColors.current
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
androidx.compose.runtime.LaunchedEffect(messages)
{
@@ -313,103 +338,147 @@ fun ProfileFormScreen(
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
}
}
Scaffold(
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
contentWindowInsets = WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
),
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
bottomBar = {
if (enabled)
Box(Modifier.fillMaxSize().background(c.background))
{
Surface(
color = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = ShxSpacing.md),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
) {
OutlinedButton(
onClick = onCancel,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(1f)
.height(48.dp),
) { Text(stringResource(R.string.profile_edit_cancel)) }
Button(
onClick = onSave,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(2f)
.height(48.dp),
) { Text(stringResource(R.string.profile_edit_save)) }
}
}
}
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState()),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onCancel))
{
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
}
Text(title, style = ShxType.pageTitle, color = c.text, maxLines = 1, modifier = Modifier.weight(1f))
}
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
if (state.readOnly)
{
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
ShxNote(stringResource(R.string.alert_text_vpn_profile_read_only), tone = ShxTone.ERROR)
}
ShxSectionCard(title = stringResource(R.string.profile_section_basic))
{
OutlinedTextField(
ShxFormField(
label = stringResource(R.string.profile_name_label).trimEnd(':'),
value = state.name,
onValueChange = { onChange(state.copy(name = it)) },
placeholder = stringResource(R.string.form_name_placeholder),
enabled = enabled,
)
ShxFormField(
label = stringResource(R.string.profile_gateway_label).trimEnd(':'),
value = state.gateway,
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
label = { Text(stringResource(R.string.profile_gateway_label)) },
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
isError = state.gatewayError != null,
placeholder = stringResource(R.string.form_gateway_placeholder),
error = state.gatewayError,
enabled = enabled,
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
OutlinedTextField(
value = types.getOrElse(state.vpnType.ordinal) { "" },
onValueChange = {},
readOnly = true,
ShxSectionHeader(stringResource(R.string.form_login))
if (state.vpnType in LOGIN_TYPES)
{
ShxTabs(
options = listOf(stringResource(R.string.form_login_password), stringResource(R.string.form_login_cert), stringResource(R.string.form_login_both)),
selected = LOGIN_TYPES.indexOf(state.vpnType),
onSelect = { if (enabled) onChange(state.copy(vpnType = LOGIN_TYPES[it])) },
)
}
else
{
ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL)
}
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
{
ShxFormField(
label = stringResource(R.string.profile_username_label).trimEnd(':'),
value = state.username,
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
error = state.usernameError,
enabled = enabled,
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
ShxFormField(
label = stringResource(R.string.profile_password_label).trimEnd(':'),
value = state.password,
onValueChange = { onChange(state.copy(password = it)) },
supporting = stringResource(R.string.profile_password_hint),
enabled = enabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailing = {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
{
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null, tint = c.textSub, modifier = Modifier.size(20.dp))
}
},
)
}
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
{
FormPickRow(
title = state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate),
subtitle = stringResource(R.string.profile_user_certificate_label).trimEnd(':'),
enabled = enabled,
)
{
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
onChange(latest.value.copy(userCertAlias = alias))
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
}
}
ShxPanel()
{
ShxToggleRow(stringResource(R.string.profile_ca_auto_label), state.caAuto, { onChange(state.copy(caAuto = it)) }, enabled = enabled, divider = !state.caAuto)
if (!state.caAuto)
{
FormPickRow(
title = state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) },
subtitle = stringResource(R.string.profile_ca_label).trimEnd(':'),
enabled = enabled,
bordered = false,
)
{
val intent = Intent(context, TrustedCertificatesActivity::class.java)
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
pickCa.launch(intent)
}
}
}
/* «More: port, MTU, DNS, identities» */
val chevron by animateFloatAsState(if (state.advanced) 90f else 0f, label = "adv")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(16.dp)).background(c.card)
.border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
.clickable(role = Role.Button) { onChange(state.copy(advanced = !state.advanced)) }.padding(horizontal = 16.dp),
)
{
Text(stringResource(R.string.form_more), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
Icon(ShxIcons.Chevron, contentDescription = stringResource(R.string.profile_show_advanced_label), tint = c.iconMuted, modifier = Modifier.size(18.dp).rotate(chevron))
}
AnimatedVisibility(
visible = state.advanced,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
{
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it })
{
FormPickRow(
title = types.getOrElse(state.vpnType.ordinal) { "" },
subtitle = stringResource(R.string.profile_vpn_type_label).trimEnd(':'),
enabled = enabled,
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
) { typeExpanded = true }
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false })
{
VpnType.values().forEachIndexed { index, type ->
DropdownMenuItem(
text = { Text(types.getOrElse(index) { type.identifier }) },
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
onClick = {
onChange(state.copy(vpnType = type))
typeExpanded = false
@@ -418,199 +487,95 @@ fun ProfileFormScreen(
}
}
}
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
ShxFormField(stringResource(R.string.profile_remote_id_label).trimEnd(':'), state.remoteId, { onChange(state.copy(remoteId = it)) },
supporting = stringResource(R.string.profile_remote_id_hint), enabled = enabled)
ShxFormField(stringResource(R.string.profile_local_id_label).trimEnd(':'), state.localId, { onChange(state.copy(localId = it)) },
supporting = stringResource(R.string.profile_local_id_hint_user), enabled = enabled)
ShxFormField(stringResource(R.string.profile_dns_servers_label).trimEnd(':'), state.dns, { onChange(state.copy(dns = it)) },
supporting = stringResource(R.string.profile_dns_servers_hint), enabled = enabled, mono = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
OutlinedTextField(
value = state.username,
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
label = { Text(stringResource(R.string.profile_username_label)) },
isError = state.usernameError != null,
supportingText = state.usernameError?.let { { Text(it) } },
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = { onChange(state.copy(password = it)) },
label = { Text(stringResource(R.string.profile_password_label)) },
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
enabled = enabled,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
ShxFormField(stringResource(R.string.profile_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
}
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
ShxPanel()
{
TextButton(onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
onChange(latest.value.copy(userCertAlias = alias))
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
}, enabled = enabled) {
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, { onChange(state.copy(ipv6Transport = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv4_title), state.blockIpv4, { onChange(state.copy(blockIpv4 = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv6_title), state.blockIpv6, { onChange(state.copy(blockIpv6 = it)) },
subtitle = stringResource(R.string.profile_split_tunnelingv6_hint), enabled = enabled)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.caAuto,
onCheckedChange = { onChange(state.copy(caAuto = it)) },
enabled = enabled,
)
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
onChange(state.copy(caAuto = !state.caAuto))
})
}
if (!state.caAuto)
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
ShxPanel()
{
TextButton(onClick = {
val intent = Intent(context, TrustedCertificatesActivity::class.java)
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
pickCa.launch(intent)
}, enabled = enabled) {
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
}
}
OutlinedTextField(
value = state.name,
onValueChange = { onChange(state.copy(name = it)) },
label = { Text(stringResource(R.string.profile_name_label)) },
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
val chevron by animateFloatAsState(if (state.advanced) 180f else 0f, label = "adv")
ListItem(
headlineContent = { Text(stringResource(R.string.profile_advanced_label)) },
supportingContent = { Text(stringResource(R.string.profile_advanced_hint)) },
trailingContent = {
Icon(
Icons.Default.ExpandMore,
contentDescription = stringResource(R.string.profile_show_advanced_label),
modifier = Modifier.rotate(chevron),
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier
.fillMaxWidth()
.clickable { onChange(state.copy(advanced = !state.advanced)) },
)
AnimatedVisibility(
visible = state.advanced,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
label = { Text(stringResource(R.string.profile_remote_id_label)) },
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
label = { Text(stringResource(R.string.profile_local_id_label)) },
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
label = { Text(stringResource(R.string.profile_mtu_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
label = { Text(stringResource(R.string.profile_port_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
label = { Text(stringResource(R.string.profile_nat_keepalive_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
isError = state.includedSubnetsError != null,
supportingText = { state.includedSubnetsError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
isError = state.excludedSubnetsError != null,
supportingText = { state.excludedSubnetsError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
SwitchRow(
label = stringResource(R.string.profile_split_tunnelingv4_title),
checked = state.blockIpv4,
enabled = enabled,
) { onChange(state.copy(blockIpv4 = it)) }
SwitchRow(
label = stringResource(R.string.profile_split_tunnelingv6_title),
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
checked = state.blockIpv6,
enabled = enabled,
) { onChange(state.copy(blockIpv6 = it)) }
ShxSettingRow(
icon = Icons.Rounded.AltRoute,
ShxRow(
title = stringResource(R.string.profile_routing_link_title),
subtitle = stringResource(R.string.profile_routing_link_summary),
onClick = {
context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
},
icon = ShxIcons.Route,
chevron = true,
onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) },
)
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
isError = state.ikeProposalError != null,
supportingText = { state.ikeProposalError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
isError = state.espProposalError != null,
supportingText = { state.espProposalError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyPort, { onChange(state.copy(proxyPort = it)) },
label = { Text(stringResource(R.string.profile_proxy_port_label)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) },
label = { Text(stringResource(R.string.profile_proxy_exclusions_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
}
ShxFormField(stringResource(R.string.profile_proposals_ike_label).trimEnd(':'), state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
error = state.ikeProposalError, enabled = enabled, mono = true)
ShxFormField(stringResource(R.string.profile_proposals_esp_label).trimEnd(':'), state.espProposal, { onChange(state.copy(espProposal = it)) },
error = state.espProposalError, enabled = enabled, mono = true)
ShxFormField(stringResource(R.string.profile_proxy_host_label).trimEnd(':'), state.proxyHost, { onChange(state.copy(proxyHost = it)) }, enabled = enabled)
ShxFormField(stringResource(R.string.profile_proxy_port_label).trimEnd(':'), state.proxyPort, { onChange(state.copy(proxyPort = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
ShxFormField(stringResource(R.string.profile_proxy_exclusions_label).trimEnd(':'), state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) }, enabled = enabled)
}
}
if (enabled)
{
ShxButton(stringResource(R.string.profile_edit_save), onSave, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
}
}
}
org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
}
}
/** White row that opens a picker: 12 label on top, 15/700 value, chevron. */
@Composable
private fun SwitchRow(
label: String,
checked: Boolean,
private fun FormPickRow(
title: String,
subtitle: String,
enabled: Boolean,
supporting: String? = null,
onChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
bordered: Boolean = true,
onClick: () -> Unit,
)
{
ListItem(
headlineContent = { Text(label) },
supportingContent = supporting?.let { { Text(it) } },
trailingContent = {
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth(),
val c = LocalShxColors.current
val shape = RoundedCornerShape(16.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.fillMaxWidth()
.then(if (bordered) Modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
Text(title, style = ShxType.body.copy(fontSize = 16.sp), color = if (enabled) c.text else c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
}
@@ -36,6 +36,7 @@ import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -48,9 +49,11 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
@@ -573,7 +576,7 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
{
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
content = content,
)
}
@@ -617,3 +620,120 @@ fun ShxIntro(text: String, modifier: Modifier = Modifier)
{
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.textSub, modifier = modifier.padding(horizontal = 4.dp))
}
/**
* Form field of the mockup (F_ManualAdd): white box, radius 16, label 12/700
* inside, value 16/600; 2px purple border while focused, red on error.
*/
@Composable
fun ShxFormField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
supporting: String? = null,
error: String? = null,
enabled: Boolean = true,
mono: Boolean = false,
singleLine: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
trailing: (@Composable () -> Unit)? = null,
)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(16.dp)
var focused by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val borderColor = when
{
error != null -> c.error
focused -> c.accent
else -> c.cardBorder
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(c.card)
.border(if (focused || error != null) 2.dp else 1.dp, borderColor, shape)
.padding(start = 14.dp, end = if (trailing != null) 4.dp else 14.dp, top = 10.dp, bottom = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(
label,
style = ShxType.meta.copy(fontWeight = FontWeight.Bold),
color = when
{
error != null -> c.error
focused -> c.accentText
else -> c.textSub
},
)
Box()
{
if (value.isEmpty() && placeholder != null)
{
Text(placeholder, style = ShxType.body.copy(fontSize = 16.sp), color = c.iconMuted, maxLines = 1)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = singleLine,
textStyle = (if (mono) ShxType.mono.copy(fontSize = 16.sp) else ShxType.body.copy(fontSize = 16.sp)).copy(color = if (enabled) c.text else c.textSub),
cursorBrush = SolidColor(c.accent),
keyboardOptions = keyboardOptions,
visualTransformation = visualTransformation,
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
)
}
}
trailing?.invoke()
}
val note = error ?: supporting
if (note != null)
{
Text(note, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = if (error != null) c.error else c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
}
}
}
/** Toggle row inside a white card: 15/700 title, optional 12 sub, the mockup switch. */
@Composable
fun ShxToggleRow(title: String, checked: Boolean, onChange: (Boolean) -> Unit, subtitle: String? = null, enabled: Boolean = true, divider: Boolean = false)
{
val c = LocalShxColors.current
Column()
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 58.dp)
.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onChange)
.padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (enabled) c.text else c.textSub)
if (subtitle != null)
{
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
ShxSwitch(checked = checked, onCheckedChange = null, enabled = enabled)
}
if (divider)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
@@ -918,4 +918,44 @@
<string name="share_send_file">Отправить файлом</string>
<string name="share_copy">Скопировать текст</string>
<string name="share_copied">Профиль скопирован</string>
<string name="import_failed_download">Не удалось скачать профиль</string>
<string name="import_failed_read">Не удалось прочитать профиль</string>
<string name="import_reasons">Возможные причины</string>
<string name="import_reason_blocked">Сайт заблокирован</string>
<string name="import_reason_blocked_d">ссылка не открывается без VPN</string>
<string name="import_reason_offline">Нет интернета</string>
<string name="import_reason_offline_d">проверьте мобильную сеть или Wi-Fi</string>
<string name="import_reason_tls">Ошибка сертификата сайта</string>
<string name="import_reason_tls_d">HTTPS-соединение не прошло проверку</string>
<string name="import_reason_format">Это не профиль</string>
<string name="import_reason_format_d">файл повреждён или это не профиль .sswan</string>
<string name="import_ask_admin">Попросите у администратора файл .sswan или QR — их можно добавить без интернета.</string>
<string name="import_support_details">Подробности для поддержки</string>
<string name="import_reading">Читаем профиль…</string>
<string name="import_add_title">Добавить сервер?</string>
<string name="import_read_ok">Профиль прочитан, всё в порядке</string>
<string name="import_name">Название</string>
<string name="import_server">Сервер</string>
<string name="import_type">Тип</string>
<string name="import_login">Вход</string>
<string name="import_login_both">Сертификат и пароль</string>
<string name="import_login_cert">Сертификат</string>
<string name="import_login_password">Логин и пароль</string>
<string name="import_source">Источник</string>
<string name="import_source_link">ссылка</string>
<string name="import_source_file">файл</string>
<string name="import_ca">Сертификат CA</string>
<string name="import_ca_included">входит в профиль</string>
<string name="import_connect_now">Подключиться сразу</string>
<string name="import_exists_title">Такой сервер уже есть.</string>
<string name="import_exists_text">Новый профиль заменит его, настройки сервера сохранятся.</string>
<string name="import_update_server">Обновить сервер</string>
<string name="import_add_server">Добавить сервер</string>
<string name="form_name_placeholder">Амстердам</string>
<string name="form_gateway_placeholder">vpn.example.com</string>
<string name="form_login">Вход</string>
<string name="form_login_password">Логин</string>
<string name="form_login_cert">Сертификат</string>
<string name="form_login_both">Оба</string>
<string name="form_more">Дополнительно: порт, MTU, DNS, идентификаторы</string>
</resources>
@@ -903,4 +903,44 @@
<string name="share_send_file">Send as a file</string>
<string name="share_copy">Copy text</string>
<string name="share_copied">Profile copied</string>
<string name="import_failed_download">Could not download the profile</string>
<string name="import_failed_read">Could not read the profile</string>
<string name="import_reasons">Possible reasons</string>
<string name="import_reason_blocked">The site is blocked</string>
<string name="import_reason_blocked_d">the link does not open without the VPN</string>
<string name="import_reason_offline">No internet</string>
<string name="import_reason_offline_d">check mobile data or Wi-Fi</string>
<string name="import_reason_tls">Site certificate error</string>
<string name="import_reason_tls_d">the HTTPS connection did not pass the check</string>
<string name="import_reason_format">Not a profile</string>
<string name="import_reason_format_d">the file is damaged or is not an .sswan profile</string>
<string name="import_ask_admin">Ask the admin for an .sswan file or a QR code: they can be added without the internet.</string>
<string name="import_support_details">Details for support</string>
<string name="import_reading">Reading the profile…</string>
<string name="import_add_title">Add the server?</string>
<string name="import_read_ok">The profile is read, all is fine</string>
<string name="import_name">Name</string>
<string name="import_server">Server</string>
<string name="import_type">Type</string>
<string name="import_login">Sign-in</string>
<string name="import_login_both">Certificate and password</string>
<string name="import_login_cert">Certificate</string>
<string name="import_login_password">Login and password</string>
<string name="import_source">Source</string>
<string name="import_source_link">link</string>
<string name="import_source_file">file</string>
<string name="import_ca">CA certificate</string>
<string name="import_ca_included">included in the profile</string>
<string name="import_connect_now">Connect right away</string>
<string name="import_exists_title">This server already exists.</string>
<string name="import_exists_text">The new profile replaces it; the settings of the server stay.</string>
<string name="import_update_server">Update the server</string>
<string name="import_add_server">Add the server</string>
<string name="form_name_placeholder">Amsterdam</string>
<string name="form_gateway_placeholder">vpn.example.com</string>
<string name="form_login">Sign-in</string>
<string name="form_login_password">Login</string>
<string name="form_login_cert">Certificate</string>
<string name="form_login_both">Both</string>
<string name="form_more">More: port, MTU, DNS, identities</string>
</resources>