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