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:name=".ui.DiagnosticsActivity"
|
||||||
android:label="@string/diag_title">
|
android:label="@string/diag_title">
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.OnboardingActivity"
|
||||||
|
android:label="@string/app_name">
|
||||||
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.RemediationInstructionsActivity"
|
android:name=".ui.RemediationInstructionsActivity"
|
||||||
android:label="@string/remediation_instructions_title" >
|
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";
|
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
|
* Use "bring your own device" (BYOD) features
|
||||||
*/
|
*/
|
||||||
@@ -55,7 +60,12 @@ public class MainActivity extends AppCompatActivity
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
handleInstallUpdate(getIntent());
|
handleInstallUpdate(getIntent());
|
||||||
handleOpenRouting(getIntent());
|
handleOpenRouting(getIntent());
|
||||||
|
handleAddServer(getIntent());
|
||||||
ShxAppKt.installShxUi(this);
|
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
|
@Override
|
||||||
@@ -70,6 +80,7 @@ public class MainActivity extends AppCompatActivity
|
|||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent)
|
protected void onNewIntent(Intent intent)
|
||||||
{
|
{
|
||||||
|
handleAddServer(intent);
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
handleInstallUpdate(intent);
|
handleInstallUpdate(intent);
|
||||||
handleOpenRouting(intent);
|
handleOpenRouting(intent);
|
||||||
@@ -92,4 +103,13 @@ public class MainActivity extends AppCompatActivity
|
|||||||
UpdateManager.getInstance(this).downloadAndInstall();
|
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)
|
if (page == SettingsPage.STABILITY)
|
||||||
{
|
{
|
||||||
|
PermissionsList(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp))
|
||||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||||
{
|
{
|
||||||
var addedRow = false
|
var addedRow = false
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
/* «Add server» sheet: QR, link, file or manual entry */
|
/* «Add server» sheet: QR, link, file or manual entry */
|
||||||
var addSheetOpen by remember { mutableStateOf(false) }
|
var addSheetOpen by remember { mutableStateOf(false) }
|
||||||
val addProfile = { addSheetOpen = true }
|
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 editProfile: (VpnProfile) -> Unit = { profile ->
|
||||||
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
||||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
||||||
|
|||||||
+14
@@ -35,4 +35,18 @@ object ShxNavigation
|
|||||||
{
|
{
|
||||||
_requestedTab.value = null
|
_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 Cert = line("cert", "M4 5h16v10H4zM8 19l4-2 4 2v-4H8z", 2f)
|
||||||
val Play = line("play", "M5 4l14 8-14 8z", 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 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)
|
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_install">Установить</string>
|
||||||
<string name="update_card_footer">Текст «Что нового» берётся из заметок релиза</string>
|
<string name="update_card_footer">Текст «Что нового» берётся из заметок релиза</string>
|
||||||
<string name="update_settings">Как обновлять</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>
|
</resources>
|
||||||
@@ -968,4 +968,30 @@
|
|||||||
<string name="update_card_install">Install</string>
|
<string name="update_card_install">Install</string>
|
||||||
<string name="update_card_footer">«What is new» comes from the release notes</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="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>
|
</resources>
|
||||||
Reference in new issue
Block a user