diff --git a/src/frontends/android/app/build.gradle b/src/frontends/android/app/build.gradle index 45009d3f4..2fe2a09a4 100644 --- a/src/frontends/android/app/build.gradle +++ b/src/frontends/android/app/build.gradle @@ -173,6 +173,8 @@ dependencies { implementation 'androidx.glance:glance-appwidget:1.2.0' implementation 'androidx.glance:glance-material3:1.2.0' implementation 'androidx.activity:activity-compose:1.11.0' + /* QR: scanning profiles (camera) and showing a profile as a QR code */ + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' debugImplementation 'androidx.compose.ui:ui-tooling' implementation 'androidx.compose.ui:ui-tooling-preview' diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/AddServerSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/AddServerSheet.kt new file mode 100644 index 000000000..cbcc9bded --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/AddServerSheet.kt @@ -0,0 +1,263 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + +package org.strongswan.android.ui.compose + +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.Color +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.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import org.strongswan.android.R +import org.strongswan.android.ui.VpnProfileDetailActivity +import org.strongswan.android.ui.VpnProfileImportActivity +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.ShxSheet +import org.strongswan.android.ui.compose.theme.LocalShxColors +import org.strongswan.android.ui.compose.theme.ShxType +import java.io.File + +/** QR codes of profiles: what a scan turns into, and a profile turned into a QR image. */ +object ProfileQr +{ + /** + * The import intent for a scanned text: a link opens the import screen on + * that URL, a .sswan document is handed over as a temporary file. + */ + fun importIntent(context: Context, text: String): Intent? + { + val trimmed = text.trim() + val intent = Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW) + if (trimmed.startsWith("https://", true) || trimmed.startsWith("http://", true)) + { + return intent.setData(Uri.parse(trimmed)) + } + if (!trimmed.startsWith("{")) + { + return null + } + val dir = File(context.cacheDir, "qr").apply { mkdirs() } + val file = File(dir, "scanned.sswan") + file.writeText(trimmed) + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + return intent.setDataAndType(uri, "application/vnd.strongswan.profile").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + /** A QR bitmap of [text], or null when it does not fit into a QR code. */ + fun bitmap(text: String, size: Int = 720, highCorrection: Boolean = false): Bitmap? = try + { + val matrix = QRCodeWriter().encode( + text, BarcodeFormat.QR_CODE, size, size, + mapOf( + EncodeHintType.ERROR_CORRECTION to if (highCorrection) ErrorCorrectionLevel.H else ErrorCorrectionLevel.L, + EncodeHintType.MARGIN to 1, + EncodeHintType.CHARACTER_SET to "UTF-8", + ), + ) + val pixels = IntArray(matrix.width * matrix.height) { i -> + if (matrix.get(i % matrix.width, i / matrix.width)) android.graphics.Color.BLACK else android.graphics.Color.WHITE + } + Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888) + } + catch (_: Exception) + { + null + } +} + +/** «Add server» sheet (F_Import): QR first, then a link, then a file or manual entry. */ +@Composable +fun AddServerSheet(onDismiss: () -> Unit) +{ + val context = LocalContext.current + val c = LocalShxColors.current + var link by remember { mutableStateOf("") } + val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> + val text = result.contents ?: return@rememberLauncherForActivityResult + val intent = ProfileQr.importIntent(context, text) + if (intent != null) + { + onDismiss() + context.startActivity(intent) + } + else + { + Toast.makeText(context, R.string.add_qr_not_profile, Toast.LENGTH_LONG).show() + } + } + fun openLink() + { + val url = link.trim() + if (!url.startsWith("http://", true) && !url.startsWith("https://", true)) + { + Toast.makeText(context, R.string.add_link_invalid, Toast.LENGTH_SHORT).show() + return + } + onDismiss() + context.startActivity(Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW).setData(Uri.parse(url))) + } + ShxSheet(onDismiss = onDismiss) + { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) + { + Text(stringResource(R.string.add_server_title), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.weight(1f)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onDismiss)) + { + Icon(ShxIcons.Close, contentDescription = stringResource(R.string.add_close), tint = c.textSoft, modifier = Modifier.size(22.dp)) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accent) + .clickable(role = Role.Button) { + scanner.launch( + ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt(context.getString(R.string.add_qr_prompt)) + .setBeepEnabled(false) + .setOrientationLocked(false) + ) + } + .padding(18.dp), + ) + { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp).clip(RoundedCornerShape(16.dp)).background(Color.White.copy(alpha = 0.18f))) + { + Icon(ShxIcons.Scan, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) + { + Text(stringResource(R.string.add_qr), style = ShxType.cardHeading, color = Color.White) + Text(stringResource(R.string.add_qr_sub), style = ShxType.sub, color = Color(0xFFE7DFFF)) + } + } + val shape = RoundedCornerShape(24.dp) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(14.dp), + ) + { + Text(stringResource(R.string.add_link_title), style = ShxType.cardTitle, color = c.text) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) + { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.tile).padding(horizontal = 12.dp), + ) + { + if (link.isEmpty()) + { + Text("https://…/profile.sswan", style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + } + BasicTextField( + value = link, + onValueChange = { link = it }, + singleLine = true, + textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text), + cursorBrush = SolidColor(c.accent), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { openLink() }), + modifier = Modifier.fillMaxWidth(), + ) + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted) + .clickable(role = Role.Button) { + if (link.isBlank()) + { + val clip = (context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip + link = clip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString()?.trim().orEmpty() + } + else + { + openLink() + } + } + .padding(horizontal = 16.dp), + ) + { + Text( + stringResource(if (link.isBlank()) R.string.add_link_paste else R.string.add_link_open), + style = ShxType.body.copy(fontWeight = FontWeight.Bold), + color = c.textTonal, + ) + } + } + Text(stringResource(R.string.add_link_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton( + stringResource(R.string.add_file), + { + onDismiss() + context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) + }, + style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f), + ) + ShxButton( + stringResource(R.string.add_manual), + { + onDismiss() + context.startActivity(Intent(context, VpnProfileDetailActivity::class.java)) + }, + style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f), + ) + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShareProfileScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShareProfileScreen.kt new file mode 100644 index 000000000..c345f5a26 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShareProfileScreen.kt @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + +package org.strongswan.android.ui.compose + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.strongswan.android.R +import org.strongswan.android.data.VpnProfile +import org.strongswan.android.logic.VpnProfileExporter +import org.strongswan.android.ui.compose.design.ShxButton +import org.strongswan.android.ui.compose.design.ShxButtonStyle +import org.strongswan.android.ui.compose.design.ShxNote +import org.strongswan.android.ui.compose.design.ShxPageHeader +import org.strongswan.android.ui.compose.design.ShxTone +import org.strongswan.android.ui.compose.home.serverCode +import org.strongswan.android.ui.compose.theme.LocalShxColors +import org.strongswan.android.ui.compose.theme.ShxType + +/** The profile as .sswan text without the password, compacted for a QR code. */ +private suspend fun profileText(context: Context, profile: VpnProfile): String? = withContext(Dispatchers.IO) { + val uri = VpnProfileExporter.exportProfile(context, profile, false) ?: return@withContext null + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }?.let { JSONObject(it).toString() } + }.getOrNull() +} + +/** «Share server» (F_Share): QR of the profile, who it is, and file / copy actions. */ +@Composable +fun ShareProfileScreen(profile: VpnProfile, onDismiss: () -> Unit, onSendFile: () -> Unit) +{ + val context = LocalContext.current + val c = LocalShxColors.current + val text by produceState(null, profile) { value = profileText(context, profile) } + /* the logo in the middle needs the high error correction; a bigger profile falls back to a plain code */ + val qr by produceState?>(null, text) { + val t = text ?: return@produceState + value = withContext(Dispatchers.Default) { + ProfileQr.bitmap(t, highCorrection = true)?.let { it to true } ?: ProfileQr.bitmap(t)?.let { it to false } + } + } + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) + { + Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding().verticalScroll(rememberScrollState())) + { + ShxPageHeader(stringResource(R.string.share_title), onDismiss) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 28.dp), + ) + { + Text(stringResource(R.string.share_hint), style = ShxType.note.copy(fontSize = 15.sp), color = c.textSoft, textAlign = TextAlign.Center) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .shadow(14.dp, RoundedCornerShape(32.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f)) + .clip(RoundedCornerShape(32.dp)) + .background(Color.White) + .border(1.dp, c.cardBorder, RoundedCornerShape(32.dp)) + .padding(22.dp) + .size(252.dp), + ) + { + val code = qr + if (code != null) + { + Image(code.first.asImageBitmap(), contentDescription = stringResource(R.string.share_qr_desc, profile.name ?: ""), modifier = Modifier.fillMaxSize()) + if (code.second) + { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)).background(Color.White).padding(4.dp).clip(RoundedCornerShape(11.dp)).background(c.accent), + ) + { + Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + else if (text != null) + { + Text(stringResource(R.string.share_too_big), style = ShxType.note, color = c.textSub, textAlign = TextAlign.Center) + } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(CircleShape).background(c.accentContainer)) + { + Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), color = c.onAccentContainer) + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) + { + Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), color = c.text) + Text(stringResource(R.string.share_without_secrets), style = ShxType.meta.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = c.textSub) + } + } + Spacer(Modifier.size(12.dp)) + ShxNote(stringResource(R.string.share_cert_note), tone = ShxTone.WARNING) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) + { + ShxButton(stringResource(R.string.share_send_file), onSendFile, height = 52.dp, modifier = Modifier.weight(1f)) + ShxButton( + stringResource(R.string.share_copy), + { + text?.let { + (context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.setPrimaryClip(ClipData.newPlainText("sswan", it)) + Toast.makeText(context, R.string.share_copied, Toast.LENGTH_SHORT).show() + } + }, + style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f), enabled = text != null, + ) + } + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index 29725fda1..14c80c204 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -108,9 +108,9 @@ fun ShxApp(viewModel: HomeViewModel = viewModel()) { val state by viewModel.ui.collectAsStateWithLifecycle() val context = LocalContext.current - val addProfile = { - context.startActivity(Intent(context, VpnProfileDetailActivity::class.java)) - } + /* «Add server» sheet: QR, link, file or manual entry */ + var addSheetOpen by remember { mutableStateOf(false) } + val addProfile = { addSheetOpen = true } val editProfile: (VpnProfile) -> Unit = { profile -> val intent = Intent(context, VpnProfileDetailActivity::class.java) intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString()) @@ -232,53 +232,20 @@ fun ShxApp(viewModel: HomeViewModel = viewModel()) ) } pendingShare?.let { profile -> - ShareProfileDialog( + ShareProfileScreen( profile = profile, onDismiss = { pendingShare = null }, - onConfirm = { includePassword -> - pendingShare = null - viewModel.shareProfile(context, profile, includePassword) - }, + onSendFile = { viewModel.shareProfile(context, profile, false) }, ) } + if (addSheetOpen) + { + AddServerSheet(onDismiss = { addSheetOpen = false }) + } } } } -@Composable -private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onConfirm: (Boolean) -> Unit) -{ - var includePassword by remember { mutableStateOf(false) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.share_profile)) }, - text = { - Column { - Text(stringResource(R.string.share_profile_password_warning), style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(ShxSpacing.md)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { includePassword = !includePassword }, - ) { - Checkbox(checked = includePassword, onCheckedChange = { includePassword = it }) - Spacer(Modifier.width(ShxSpacing.xs)) - Text(stringResource(R.string.share_profile_include_password)) - } - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(includePassword) }) { - Text(stringResource(R.string.share_profile)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) -} - /** Item colors of the mockup nav bar: #E7DFFF pill, #1C1B20 selected, #49454F the rest. */ @Composable private fun shxNavigationItemColors(): NavigationSuiteItemColors diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt index 9e855e017..bd036bc56 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt @@ -69,5 +69,6 @@ object ShxIcons val Doc = line("doc", "M6 3h9l4 4v14H6zM9 12h6M9 16h6", 2f) val Cert = line("cert", "M4 5h16v10H4zM8 19l4-2 4 2v-4H8z", 2f) val Play = line("play", "M5 4l14 8-14 8z", 2f) + val Scan = line("scan", "M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16", 2f) val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f) } 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 935cde663..93ffa7956 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 @@ -290,7 +290,7 @@ fun ShxSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: } } -enum class ShxButtonStyle { PRIMARY, TONAL, OUTLINE, WARNING, DANGER } +enum class ShxButtonStyle { PRIMARY, TONAL, SOFT, OUTLINE, WARNING, DANGER } /** Buttons of the mockup: 56 (large), 48 (medium) or 36 (small) high. */ @Composable @@ -316,6 +316,7 @@ fun ShxButton( { ShxButtonStyle.PRIMARY -> c.accent to c.onAccent ShxButtonStyle.TONAL -> c.tile to c.textTonal + ShxButtonStyle.SOFT -> c.accentMuted to c.textTonal ShxButtonStyle.OUTLINE -> Color.Transparent to c.text ShxButtonStyle.WARNING -> c.warning to Color.White ShxButtonStyle.DANGER -> c.errorContainer to c.onErrorContainer 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 7dcb01f52..37c855bfa 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 @@ -896,4 +896,26 @@ %d доверенных сертификата Включённые приложения ходят в сеть напрямую, мимо VPN. Банки и сервисы РФ часто не работают через VPN. + Добавить сервер + Закрыть + Сканировать QR + С экрана друга или с сайта + Наведите камеру на QR-код профиля + Этот QR-код — не профиль VPN + Ссылка на профиль + Вставить + Открыть + Профиль скачается по ссылке — перед добавлением покажем, что в нём + Ссылка начинается с https:// + Файл .sswan + Вручную + Поделиться сервером + Пусть друг откроет SHX → «Серверы» → «+» → «Сканировать QR». Интернет не нужен. + QR-код профиля %1$s + Профиль не помещается в QR-код. Отправьте его файлом. + профиль .sswan · без пароля + Если профиль входит по сертификату, другу понадобится свой — в QR-коде его нет. + Отправить файлом + Скопировать текст + Профиль скопирован 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 5ae83b9eb..3b16aea46 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -881,4 +881,26 @@ %d trusted certificates Enabled apps reach the network directly, bypassing the VPN. Banks and RU services often do not work through a VPN. + Add a server + Close + Scan a QR code + From a friend\'s screen or a website + Point the camera at the profile QR code + This QR code is not a VPN profile + Profile link + Paste + Open + The profile is downloaded from the link; you see what is inside before adding it + A link starts with https:// + .sswan file + Manually + Share the server + Let your friend open SHX → «Servers» → «+» → «Scan a QR code». No internet needed. + QR code of the profile %1$s + The profile is too big for a QR code. Send it as a file. + .sswan profile · without password + If the profile signs in with a certificate, your friend needs their own: it is not in the QR code. + Send as a file + Copy text + Profile copied diff --git a/src/frontends/android/app/src/main/res/xml/file_paths.xml b/src/frontends/android/app/src/main/res/xml/file_paths.xml index 8ea77a66f..b53648470 100644 --- a/src/frontends/android/app/src/main/res/xml/file_paths.xml +++ b/src/frontends/android/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,6 @@ + +