diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index 2d1804056..9ae02a177 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -141,6 +141,10 @@
android:name=".ui.DiagnosticsActivity"
android:label="@string/diag_title">
+
+
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java
index 73e064800..cba83a9a7 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/MainActivity.java
@@ -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();
+ }
+ }
}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/OnboardingActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/OnboardingActivity.kt
new file mode 100644
index 000000000..0eada9538
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/OnboardingActivity.kt
@@ -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 .
+ *
+ * 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))
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt
new file mode 100644
index 000000000..e839d9ada
--- /dev/null
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
index 3ffb0591b..5b3d62731 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt
@@ -250,6 +250,7 @@ fun SettingsScreen(profiles: List, 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
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 14c80c204..1f1199af4 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
@@ -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())
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt
index c53cdf597..f571b7455 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxNavigation.kt
@@ -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 = _addServer
+
+ fun requestAddServer()
+ {
+ _addServer.value = true
+ }
+
+ fun consumeAddServer()
+ {
+ _addServer.value = false
+ }
}
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 bd036bc56..e438e22e6 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
@@ -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)
}
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 7e1536355..23dca3e91 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
@@ -983,4 +983,30 @@
Установить
Текст «Что нового» берётся из заметок релиза
Как обновлять
+ Пропустить
+ Госуслуги → напрямую
+ YouTube → через VPN
+ Шаг %1$d из %2$d
+ Российское — напрямую, остальное — через VPN
+ Банки и Госуслуги работают как обычно, а заблокированные сервисы открываются без переключений.
+ Начать
+ Продолжить
+ Чтобы VPN работал стабильно
+ Android может выключать VPN в фоне. Разрешите главное — остальное можно позже.
+ Всё это можно изменить в Настройки → Стабильная работа
+ VPN-подключение
+ Системное разрешение — без него VPN не включится
+ Уведомления
+ Статус VPN и предупреждения о блокировках
+ Без экономии батареи
+ Иначе Android отключает VPN через 5–30 минут в фоне
+ Статистика использования
+ Для «Недавних приложений»
+ Геолокация
+ Только чтобы видеть имя Wi-Fi для доверенных сетей. Место не храним
+ нужно
+ важно
+ по желанию
+ Разрешить
+ Готово
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 acbb1e396..251dfcff3 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -968,4 +968,30 @@
Install
«What is new» comes from the release notes
How to update
+ Skip
+ Gosuslugi → direct
+ YouTube → via VPN
+ Step %1$d of %2$d
+ Russian sites direct, the rest through the VPN
+ Banks and Gosuslugi work as usual, and blocked services open without switching anything.
+ Start
+ Continue
+ For the VPN to work reliably
+ Android may turn the VPN off in the background. Allow the main things, the rest can wait.
+ All of this can be changed in Settings → Stable work
+ VPN connection
+ A system permission: the VPN does not start without it
+ Notifications
+ VPN status and warnings about blocking
+ No battery saving
+ Otherwise Android turns the VPN off after 5–30 minutes in the background
+ Usage access
+ For «Recent apps»
+ Location
+ Only to see the Wi-Fi name for trusted networks. The location is not stored
+ required
+ important
+ optional
+ Allow
+ Done