android: first-run onboarding and permissions screen as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:08:57 +07:00
1 parent dc8f5a75e1
commit 6652970925
10 files changed
+540

No files matched your search

@@ -141,6 +141,10 @@
android:name=".ui.DiagnosticsActivity"
android:label="@string/diag_title">
</activity>
<activity
android:name=".ui.OnboardingActivity"
android:label="@string/app_name">
</activity>
<activity
android:name=".ui.RemediationInstructionsActivity"
android:label="@string/remediation_instructions_title" >
@@ -42,6 +42,11 @@ public class MainActivity extends AppCompatActivity
*/
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
/**
* Set as an intent extra to open the «Add server» sheet (end of the first-run screens)
*/
public static final String EXTRA_ADD_SERVER = "org.strongswan.android.extra.ADD_SERVER";
/**
* Use "bring your own device" (BYOD) features
*/
@@ -55,7 +60,12 @@ public class MainActivity extends AppCompatActivity
super.onCreate(savedInstanceState);
handleInstallUpdate(getIntent());
handleOpenRouting(getIntent());
handleAddServer(getIntent());
ShxAppKt.installShxUi(this);
if (savedInstanceState == null && OnboardingActivity.isDue(this))
{ /* a fresh install: what the app does and the permissions first */
startActivity(new Intent(this, OnboardingActivity.class));
}
}
@Override
@@ -70,6 +80,7 @@ public class MainActivity extends AppCompatActivity
@Override
protected void onNewIntent(Intent intent)
{
handleAddServer(intent);
super.onNewIntent(intent);
handleInstallUpdate(intent);
handleOpenRouting(intent);
@@ -92,4 +103,13 @@ public class MainActivity extends AppCompatActivity
UpdateManager.getInstance(this).downloadAndInstall();
}
}
private void handleAddServer(Intent intent)
{
if (intent != null && intent.getBooleanExtra(EXTRA_ADD_SERVER, false))
{
intent.removeExtra(EXTRA_ADD_SERVER);
ShxNavigation.INSTANCE.requestAddServer();
}
}
}
@@ -0,0 +1,232 @@
/*
* 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. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
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.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.ui.compose.PermissionsList
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.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
/**
* First run (F_Onboarding → F_Permissions → add a server): what the app
* does, the permissions it needs, then Home with the «Add server» sheet.
*/
class OnboardingActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
var step by rememberSaveable { mutableIntStateOf(0) }
BackHandler(enabled = step > 0) { step-- }
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background).windowInsetsPadding(WindowInsets.safeDrawing))
{
when (step)
{
0 -> IntroStep(onStart = { step = 1 }, onSkip = ::finishToHome, onScan = { finishToHome(addServer = true) })
else -> PermissionsStep(onContinue = { finishToHome(addServer = true) })
}
}
}
}
}
private fun finishToHome(addServer: Boolean = false)
{
markDone(this)
startActivity(
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(MainActivity.EXTRA_ADD_SERVER, addServer)
)
finish()
}
companion object
{
private const val PREF_DONE = "pref_onboarding_done"
/** Whether the first-run screens are still due: a fresh install that has not seen them. */
@JvmStatic
fun isDue(context: Context): Boolean
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (prefs.getBoolean(PREF_DONE, false))
{
return false
}
/* an update of an existing installation never shows them */
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
if (info != null && info.firstInstallTime != info.lastUpdateTime)
{
markDone(context)
return false
}
return true
}
@JvmStatic
fun markDone(context: Context) = PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(PREF_DONE, true).apply()
}
}
/** Step 1: the two blobs with the route bubbles, the promise, Start / Scan QR. */
@Composable
private fun IntroStep(onStart: () -> Unit, onSkip: () -> Unit, onScan: () -> Unit)
{
val c = LocalShxColors.current
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 28.dp))
{
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End)
{
Text(
stringResource(R.string.onb_skip), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.textSub,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onSkip).padding(horizontal = 8.dp, vertical = 12.dp),
)
}
Box(Modifier.fillMaxWidth().height(290.dp).padding(top = 8.dp), contentAlignment = Alignment.Center)
{
Box(
Modifier.size(260.dp).clip(RoundedCornerShape(topStartPercent = 42, topEndPercent = 58, bottomEndPercent = 50, bottomStartPercent = 50))
.background(c.accentContainer),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(180.dp).clip(RoundedCornerShape(topStartPercent = 55, topEndPercent = 45, bottomEndPercent = 48, bottomStartPercent = 52))
.background(c.accent),
)
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(84.dp))
}
Bubble(stringResource(R.string.onb_bubble_direct), Modifier.align(Alignment.TopStart).offset(x = 10.dp, y = 46.dp))
Bubble(stringResource(R.string.onb_bubble_vpn), Modifier.align(Alignment.BottomEnd).offset(x = (-2).dp, y = (-50).dp))
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 20.dp))
{
Text(stringResource(R.string.onb_step, 1, 3).uppercase(), style = ShxType.section.copy(letterSpacing = androidx.compose.ui.unit.TextUnit(0.06f, androidx.compose.ui.unit.TextUnitType.Em)), color = c.accentText)
Text(stringResource(R.string.onb_title), style = ShxType.screenTitle.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text)
Text(stringResource(R.string.onb_text), style = ShxType.note.copy(fontSize = 16.sp, lineHeight = 24.sp), color = c.textSoft)
}
Spacer(Modifier.height(32.dp))
Dots(0)
Spacer(Modifier.height(20.dp))
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.onb_start), onStart, modifier = Modifier.fillMaxWidth())
ShxButton(stringResource(R.string.add_qr), onScan, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), icon = ShxIcons.Scan)
}
}
}
/** Step 2: the permissions list with Continue. */
@Composable
private fun PermissionsStep(onContinue: () -> Unit)
{
val c = LocalShxColors.current
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 28.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 8.dp, bottom = 18.dp))
{
Text(stringResource(R.string.onb_step, 2, 3).uppercase(), style = ShxType.section, color = c.accentText)
Text(stringResource(R.string.perm_title), style = ShxType.screenTitle.copy(fontSize = 28.sp, lineHeight = 32.sp), color = c.text)
Text(stringResource(R.string.perm_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textSoft)
}
PermissionsList()
Spacer(Modifier.height(24.dp))
ShxButton(stringResource(R.string.onb_continue), onContinue, modifier = Modifier.fillMaxWidth())
Text(
stringResource(R.string.perm_later), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
}
}
@Composable
private fun Bubble(text: String, modifier: Modifier)
{
val c = LocalShxColors.current
Text(
text,
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
color = c.onAccentContainer,
modifier = modifier.shadow(6.dp, RoundedCornerShape(14.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f))
.clip(RoundedCornerShape(14.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 8.dp),
)
}
@Composable
private fun Dots(current: Int)
{
val c = LocalShxColors.current
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally))
{
repeat(3) { i ->
Box(Modifier.width(if (i == current) 28.dp else 8.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(if (i == current) c.accent else c.outline))
}
}
}
@@ -0,0 +1,206 @@
/*
* 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.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.FlowRow
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.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.vector.ImageVector
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.strongswan.android.R
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** How much a permission matters, as the tag on its row. */
enum class PermissionLevel { REQUIRED, IMPORTANT, OPTIONAL }
/** Current state of the five permissions the VPN works best with. */
data class PermissionStatus(
val vpn: Boolean,
val notifications: Boolean,
val battery: Boolean,
val usage: Boolean,
val location: Boolean,
)
@SuppressLint("BatteryLife")
fun permissionStatus(context: Context): PermissionStatus
{
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
return PermissionStatus(
vpn = runCatching { VpnService.prepare(context) == null }.getOrDefault(false),
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled(),
battery = pm?.isIgnoringBatteryOptimizations(context.packageName) ?: true,
usage = AppTraffic.hasAccess(context),
location = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED,
)
}
/**
* «For the VPN to work reliably» (F_Permissions): one card per permission
* with its tag (required / important / optional), why it is needed, and
* a green check or an «Allow» button. Re-checked whenever the screen resumes.
*/
@SuppressLint("BatteryLife")
@Composable
fun PermissionsList(modifier: Modifier = Modifier)
{
val context = LocalContext.current
var resumes by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val status = remember(resumes) { permissionStatus(context) }
val vpnLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { resumes++ }
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { resumes++ }
fun open(intent: Intent) = runCatching { context.startActivity(intent) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp))
{
PermissionCard(
ShxIcons.Shield, stringResource(R.string.perm_vpn), PermissionLevel.REQUIRED, stringResource(R.string.perm_vpn_why), status.vpn,
) { runCatching { VpnService.prepare(context) }.getOrNull()?.let { vpnLauncher.launch(it) } }
PermissionCard(
ShxIcons.Bell, stringResource(R.string.perm_notifications), PermissionLevel.REQUIRED, stringResource(R.string.perm_notifications_why), status.notifications,
)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
{
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
}
}
PermissionCard(
ShxIcons.Phone, stringResource(R.string.perm_battery), PermissionLevel.IMPORTANT, stringResource(R.string.perm_battery_why), status.battery,
) { open(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.packageName))) }
PermissionCard(
ShxIcons.Stats, stringResource(R.string.perm_usage), PermissionLevel.OPTIONAL, stringResource(R.string.perm_usage_why), status.usage,
)
{
if (open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))).isFailure)
{
open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}
}
PermissionCard(
ShxIcons.Pin, stringResource(R.string.perm_location), PermissionLevel.OPTIONAL, stringResource(R.string.perm_location_why), status.location,
) { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
}
}
@Composable
private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLevel, why: String, done: Boolean, onAllow: () -> Unit)
{
val c = LocalShxColors.current
val (tagBg, tagFg, tag) = when (level)
{
PermissionLevel.REQUIRED -> Triple(c.accent, Color.White, stringResource(R.string.perm_tag_required))
PermissionLevel.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.perm_tag_important))
PermissionLevel.OPTIONAL -> Triple(c.tile, c.textSoft, stringResource(R.string.perm_tag_optional))
}
val border = if (done || level == PermissionLevel.OPTIONAL) c.cardBorder else c.accentBorder
val shape = RoundedCornerShape(22.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, border, shape).padding(14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(if (done) c.successContainer else c.tile))
{
Icon(icon, contentDescription = null, tint = if (done) c.onSuccess else c.onAccentContainer, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), itemVerticalAlignment = Alignment.CenterVertically)
{
Text(title, style = ShxType.cardTitle, color = c.text)
Text(
tag.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 6.dp, vertical = 1.dp),
)
}
Text(why, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
}
if (done)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(32.dp).clip(CircleShape).background(c.success))
{
Icon(ShxIcons.Check, contentDescription = stringResource(R.string.perm_done), tint = Color.White, modifier = Modifier.size(18.dp))
}
}
else
{
val optional = level == PermissionLevel.OPTIONAL
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(if (optional) c.tile else c.accent)
.clickable(role = Role.Button, onClick = onAllow).padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (optional) c.onAccentContainer else Color.White)
}
}
}
}
@@ -250,6 +250,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.STABILITY)
{
PermissionsList(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp))
ShxSection(title = stringResource(R.string.settings_section_general))
{
var addedRow = false
@@ -111,6 +111,16 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
/* «Add server» sheet: QR, link, file or manual entry */
var addSheetOpen by remember { mutableStateOf(false) }
val addProfile = { addSheetOpen = true }
val addRequested by ShxNavigation.addServer.collectAsStateWithLifecycle()
LaunchedEffect(addRequested)
{
if (addRequested)
{
viewModel.setTab(ShxTab.HOME)
addSheetOpen = true
ShxNavigation.consumeAddServer()
}
}
val editProfile: (VpnProfile) -> Unit = { profile ->
val intent = Intent(context, VpnProfileDetailActivity::class.java)
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
@@ -35,4 +35,18 @@ object ShxNavigation
{
_requestedTab.value = null
}
/* «open the Add server sheet» request (end of the first-run screens) */
private val _addServer = MutableStateFlow(false)
val addServer: StateFlow<Boolean> = _addServer
fun requestAddServer()
{
_addServer.value = true
}
fun consumeAddServer()
{
_addServer.value = false
}
}
@@ -70,5 +70,6 @@ object ShxIcons
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 Pin = line("pin", "M12 21s-7-6.1-7-11a7 7 0 0 1 14 0c0 4.9-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5", 2f)
val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f)
}
@@ -983,4 +983,30 @@
<string name="update_card_install">Установить</string>
<string name="update_card_footer">Текст «Что нового» берётся из заметок релиза</string>
<string name="update_settings">Как обновлять</string>
<string name="onb_skip">Пропустить</string>
<string name="onb_bubble_direct">Госуслуги → напрямую</string>
<string name="onb_bubble_vpn">YouTube → через VPN</string>
<string name="onb_step">Шаг %1$d из %2$d</string>
<string name="onb_title">Российское — напрямую, остальное — через VPN</string>
<string name="onb_text">Банки и Госуслуги работают как обычно, а заблокированные сервисы открываются без переключений.</string>
<string name="onb_start">Начать</string>
<string name="onb_continue">Продолжить</string>
<string name="perm_title">Чтобы VPN работал стабильно</string>
<string name="perm_intro">Android может выключать VPN в фоне. Разрешите главное — остальное можно позже.</string>
<string name="perm_later">Всё это можно изменить в Настройки → Стабильная работа</string>
<string name="perm_vpn">VPN-подключение</string>
<string name="perm_vpn_why">Системное разрешение — без него VPN не включится</string>
<string name="perm_notifications">Уведомления</string>
<string name="perm_notifications_why">Статус VPN и предупреждения о блокировках</string>
<string name="perm_battery">Без экономии батареи</string>
<string name="perm_battery_why">Иначе Android отключает VPN через 5–30 минут в фоне</string>
<string name="perm_usage">Статистика использования</string>
<string name="perm_usage_why">Для «Недавних приложений»</string>
<string name="perm_location">Геолокация</string>
<string name="perm_location_why">Только чтобы видеть имя Wi-Fi для доверенных сетей. Место не храним</string>
<string name="perm_tag_required">нужно</string>
<string name="perm_tag_important">важно</string>
<string name="perm_tag_optional">по желанию</string>
<string name="perm_allow">Разрешить</string>
<string name="perm_done">Готово</string>
</resources>
@@ -968,4 +968,30 @@
<string name="update_card_install">Install</string>
<string name="update_card_footer">«What is new» comes from the release notes</string>
<string name="update_settings">How to update</string>
<string name="onb_skip">Skip</string>
<string name="onb_bubble_direct">Gosuslugi → direct</string>
<string name="onb_bubble_vpn">YouTube → via VPN</string>
<string name="onb_step">Step %1$d of %2$d</string>
<string name="onb_title">Russian sites direct, the rest through the VPN</string>
<string name="onb_text">Banks and Gosuslugi work as usual, and blocked services open without switching anything.</string>
<string name="onb_start">Start</string>
<string name="onb_continue">Continue</string>
<string name="perm_title">For the VPN to work reliably</string>
<string name="perm_intro">Android may turn the VPN off in the background. Allow the main things, the rest can wait.</string>
<string name="perm_later">All of this can be changed in Settings → Stable work</string>
<string name="perm_vpn">VPN connection</string>
<string name="perm_vpn_why">A system permission: the VPN does not start without it</string>
<string name="perm_notifications">Notifications</string>
<string name="perm_notifications_why">VPN status and warnings about blocking</string>
<string name="perm_battery">No battery saving</string>
<string name="perm_battery_why">Otherwise Android turns the VPN off after 5–30 minutes in the background</string>
<string name="perm_usage">Usage access</string>
<string name="perm_usage_why">For «Recent apps»</string>
<string name="perm_location">Location</string>
<string name="perm_location_why">Only to see the Wi-Fi name for trusted networks. The location is not stored</string>
<string name="perm_tag_required">required</string>
<string name="perm_tag_important">important</string>
<string name="perm_tag_optional">optional</string>
<string name="perm_allow">Allow</string>
<string name="perm_done">Done</string>
</resources>