diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java index 23a700c55..ea17cd040 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileImportActivity.java @@ -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()); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ImportScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ImportScreen.kt index 8c1bba17d..40126ce97 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ImportScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ImportScreen.kt @@ -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(null) var remoteCertSecondary by mutableStateOf(null) var hideImport by mutableStateOf(false) + /** uri scheme of the profile source (https, content, file) */ + var source by mutableStateOf(null) + var sourceUri by mutableStateOf(null) + /** set when the profile could not be read; the sheet shows it with a retry */ + var loadError by mutableStateOf(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) -> + { + /* 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)) { - TextButton(onClick = onImport) { Text(stringResource(R.string.profile_edit_import)) } + Icon(ShxIcons.Alert, contentDescription = null, tint = c.error, modifier = Modifier.size(26.dp)) + } + 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, + ) + model.sourceUri?.let { + Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis) + } } - }, - colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - ), - ) - if (model.loading) - { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - LoadingIndicator() } - return - } - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = ShxSpacing.lg) - .padding(bottom = ShxSpacing.xl), - ) - { - if (model.existsWarning != null) + val shape = RoundedCornerShape(22.dp) + Column( + 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), + ) { - ImportBanner( - text = model.existsWarning ?: "", - container = MaterialTheme.colorScheme.errorContainer, - content = MaterialTheme.colorScheme.onErrorContainer, - ) - } - 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) - } - - 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(), + 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)) + { + 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, ) } } - if (model.sharedSecretWarning) + (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) { - ImportBanner( - text = stringResource(R.string.profile_import_shared_secret), - container = MaterialTheme.colorScheme.surfaceContainerHigh, - content = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = ShxSpacing.sm), + 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(), ) } - } - - 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 { - 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, - ) - 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)) - } - } - 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)) - } - } + 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.showRemoteCert) + if (model.requireUserPass) + { + 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) + { + ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL) + } + } + if (model.requireUserCert) + { + 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, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.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) + { + Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider)) + Text( + 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), + ) + } + } + } + val toggleShape = RoundedCornerShape(24.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + 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), + ) + { + 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( - 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), + 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, ) - ShxCard { - ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false) - } } } } } +/** Field of the manual/import forms: white box, radius 16, label 12/700, value 16/600. */ @Composable -private fun ImportBanner( - text: String, - container: androidx.compose.ui.graphics.Color, - content: androidx.compose.ui.graphics.Color, - modifier: Modifier = Modifier, -) +private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit) { - 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), + 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(), ) } } -@Composable -private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true) -{ - Column { - 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), - ) - 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), - ) - } - } -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt index 4fc4e5dbd..86ab3c693 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt @@ -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,304 +338,244 @@ 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) - { - 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 -> + Box(Modifier.fillMaxSize().background(c.background)) + { Column( Modifier .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()), ) { - if (state.readOnly) + 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)) { - Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error) + 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)) } - ShxSectionCard(title = stringResource(R.string.profile_section_basic)) + Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( + if (state.readOnly) + { + ShxNote(stringResource(R.string.alert_text_vpn_profile_read_only), tone = ShxTone.ERROR) + } + 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, - enabled = enabled, - label = { Text(stringResource(R.string.profile_vpn_type_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, - modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + 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])) }, ) - ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) { - VpnType.values().forEachIndexed { index, type -> - DropdownMenuItem( - text = { Text(types.getOrElse(index) { type.identifier }) }, - onClick = { - onChange(state.copy(vpnType = type)) - typeExpanded = false - }, - ) - } - } + } + else + { + ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL) } if (state.vpnType.has(VpnTypeFeature.USER_PASS)) { - OutlinedTextField( + ShxFormField( + label = stringResource(R.string.profile_username_label).trimEnd(':'), 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) } }, + error = state.usernameError, enabled = enabled, - singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ShxFormField( + label = stringResource(R.string.profile_password_label).trimEnd(':'), 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)) }, + supporting = stringResource(R.string.profile_password_hint), enabled = enabled, - singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null) + 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)) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth(), ) } if (state.vpnType.has(VpnTypeFeature.CERTIFICATE)) { - TextButton(onClick = { + 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) - }, enabled = enabled) { - Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate)) } } - 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) + 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) }) + 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) + } } } - 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)) }, + /* «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(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, - 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)) - }, - ) - 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()) + 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 }, style = ShxType.body) }, + onClick = { + onChange(state.copy(vpnType = type)) + typeExpanded = false + }, + ) + } + } + } + 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)) + { + 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)) + } + ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) }, + enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + ShxPanel() + { + 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) + } + 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() + { + ShxRow( + title = stringResource(R.string.profile_routing_link_title), + subtitle = stringResource(R.string.profile_routing_link_summary), + icon = ShxIcons.Route, + chevron = true, + onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) }, + ) + } + 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)) + } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt index 93ffa7956..5b5e099f6 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt @@ -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)) + } + } +} diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 37c855bfa..45976a26f 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -918,4 +918,44 @@ Отправить файлом Скопировать текст Профиль скопирован + Не удалось скачать профиль + Не удалось прочитать профиль + Возможные причины + Сайт заблокирован + ссылка не открывается без VPN + Нет интернета + проверьте мобильную сеть или Wi-Fi + Ошибка сертификата сайта + HTTPS-соединение не прошло проверку + Это не профиль + файл повреждён или это не профиль .sswan + Попросите у администратора файл .sswan или QR — их можно добавить без интернета. + Подробности для поддержки + Читаем профиль… + Добавить сервер? + Профиль прочитан, всё в порядке + Название + Сервер + Тип + Вход + Сертификат и пароль + Сертификат + Логин и пароль + Источник + ссылка + файл + Сертификат CA + входит в профиль + Подключиться сразу + Такой сервер уже есть. + Новый профиль заменит его, настройки сервера сохранятся. + Обновить сервер + Добавить сервер + Амстердам + vpn.example.com + Вход + Логин + Сертификат + Оба + Дополнительно: порт, MTU, DNS, идентификаторы diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 3b16aea46..5c1787b02 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -903,4 +903,44 @@ Send as a file Copy text Profile copied + Could not download the profile + Could not read the profile + Possible reasons + The site is blocked + the link does not open without the VPN + No internet + check mobile data or Wi-Fi + Site certificate error + the HTTPS connection did not pass the check + Not a profile + the file is damaged or is not an .sswan profile + Ask the admin for an .sswan file or a QR code: they can be added without the internet. + Details for support + Reading the profile… + Add the server? + The profile is read, all is fine + Name + Server + Type + Sign-in + Certificate and password + Certificate + Login and password + Source + link + file + CA certificate + included in the profile + Connect right away + This server already exists. + The new profile replaces it; the settings of the server stay. + Update the server + Add the server + Amsterdam + vpn.example.com + Sign-in + Login + Certificate + Both + More: port, MTU, DNS, identities