android: add-server sheet with QR scan and link, share-by-QR screen (Final 2026)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 09:49:51 +07:00
1 parent 434e5bad4e
commit 200697216d
9 files changed
+487 -43

No files matched your search

+2
View File
@@ -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'
@@ -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),
)
}
}
}
@@ -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<String?>(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<Pair<android.graphics.Bitmap, Boolean>?>(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,
)
}
}
}
}
}
@@ -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
@@ -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)
}
@@ -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
@@ -896,4 +896,26 @@
<item quantity="other">%d доверенных сертификата</item>
</plurals>
<string name="apps_intro">Включённые приложения ходят в сеть напрямую, мимо VPN. Банки и сервисы РФ часто не работают через VPN.</string>
<string name="add_server_title">Добавить сервер</string>
<string name="add_close">Закрыть</string>
<string name="add_qr">Сканировать QR</string>
<string name="add_qr_sub">С экрана друга или с сайта</string>
<string name="add_qr_prompt">Наведите камеру на QR-код профиля</string>
<string name="add_qr_not_profile">Этот QR-код — не профиль VPN</string>
<string name="add_link_title">Ссылка на профиль</string>
<string name="add_link_paste">Вставить</string>
<string name="add_link_open">Открыть</string>
<string name="add_link_note">Профиль скачается по ссылке — перед добавлением покажем, что в нём</string>
<string name="add_link_invalid">Ссылка начинается с https://</string>
<string name="add_file">Файл .sswan</string>
<string name="add_manual">Вручную</string>
<string name="share_title">Поделиться сервером</string>
<string name="share_hint">Пусть друг откроет SHX → «Серверы» → «+» → «Сканировать QR». Интернет не нужен.</string>
<string name="share_qr_desc">QR-код профиля %1$s</string>
<string name="share_too_big">Профиль не помещается в QR-код. Отправьте его файлом.</string>
<string name="share_without_secrets">профиль .sswan · без пароля</string>
<string name="share_cert_note">Если профиль входит по сертификату, другу понадобится свой — в QR-коде его нет.</string>
<string name="share_send_file">Отправить файлом</string>
<string name="share_copy">Скопировать текст</string>
<string name="share_copied">Профиль скопирован</string>
</resources>
@@ -881,4 +881,26 @@
<item quantity="other">%d trusted certificates</item>
</plurals>
<string name="apps_intro">Enabled apps reach the network directly, bypassing the VPN. Banks and RU services often do not work through a VPN.</string>
<string name="add_server_title">Add a server</string>
<string name="add_close">Close</string>
<string name="add_qr">Scan a QR code</string>
<string name="add_qr_sub">From a friend\'s screen or a website</string>
<string name="add_qr_prompt">Point the camera at the profile QR code</string>
<string name="add_qr_not_profile">This QR code is not a VPN profile</string>
<string name="add_link_title">Profile link</string>
<string name="add_link_paste">Paste</string>
<string name="add_link_open">Open</string>
<string name="add_link_note">The profile is downloaded from the link; you see what is inside before adding it</string>
<string name="add_link_invalid">A link starts with https://</string>
<string name="add_file">.sswan file</string>
<string name="add_manual">Manually</string>
<string name="share_title">Share the server</string>
<string name="share_hint">Let your friend open SHX → «Servers» → «+» → «Scan a QR code». No internet needed.</string>
<string name="share_qr_desc">QR code of the profile %1$s</string>
<string name="share_too_big">The profile is too big for a QR code. Send it as a file.</string>
<string name="share_without_secrets">.sswan profile · without password</string>
<string name="share_cert_note">If the profile signs in with a certificate, your friend needs their own: it is not in the QR code.</string>
<string name="share_send_file">Send as a file</string>
<string name="share_copy">Copy text</string>
<string name="share_copied">Profile copied</string>
</resources>
@@ -2,4 +2,6 @@
<paths>
<!-- downloaded update APKs served to the system package installer -->
<files-path name="updates" path="update/" />
<!-- a profile scanned from a QR code, handed to the import screen -->
<cache-path name="qr" path="qr/" />
</paths>