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:
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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())
|
||||
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -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>
|
||||
Reference in new issue
Block a user