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:
1 parent
434e5bad4e
commit
200697216d
9 files changed
+487
-43
No files matched your search
@@ -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'
|
||||
|
||||
+263
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-42
@@ -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
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
+2
-1
@@ -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>
|
||||
Reference in new issue
Block a user