android: battery saving instructions screen as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9c96c36fc6
commit
8fbebed140
5 files changed
+296
-3
No files matched your search
@@ -79,6 +79,9 @@
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.BatteryActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.VpnProfileControlActivity"
|
||||
android:theme="@style/TransparentActivity"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxChip
|
||||
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
|
||||
|
||||
/** «Without battery saving» (F_Battery): the system exemption, then the vendor switches. */
|
||||
class BatteryActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
BatteryScreen(onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* phone makers with their own background killers; the steps differ per brand */
|
||||
private enum class Brand(val label: Int, val steps: List<Int>)
|
||||
{
|
||||
XIAOMI(R.string.battery_brand_xiaomi, listOf(R.string.battery_step_open, R.string.battery_xiaomi_2, R.string.battery_xiaomi_3)),
|
||||
SAMSUNG(R.string.battery_brand_samsung, listOf(R.string.battery_step_open, R.string.battery_samsung_2, R.string.battery_samsung_3)),
|
||||
HUAWEI(R.string.battery_brand_huawei, listOf(R.string.battery_step_open, R.string.battery_huawei_2, R.string.battery_huawei_3)),
|
||||
OPPO(R.string.battery_brand_oppo, listOf(R.string.battery_step_open, R.string.battery_oppo_2, R.string.battery_oppo_3)),
|
||||
OTHER(R.string.battery_brand_other, listOf(R.string.battery_step_open, R.string.battery_other_2)),
|
||||
}
|
||||
|
||||
private fun detectBrand(): Brand
|
||||
{
|
||||
val m = (Build.MANUFACTURER + " " + Build.BRAND).lowercase()
|
||||
return when
|
||||
{
|
||||
listOf("xiaomi", "redmi", "poco").any { it in m } -> Brand.XIAOMI
|
||||
"samsung" in m -> Brand.SAMSUNG
|
||||
"huawei" in m || "honor" in m -> Brand.HUAWEI
|
||||
listOf("oppo", "realme", "oneplus").any { it in m } -> Brand.OPPO
|
||||
else -> Brand.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExempt(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isIgnoringBatteryOptimizations(context.packageName) ?: true
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@Composable
|
||||
private fun BatteryScreen(onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var checks by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) checks++ }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val exempt = remember(checks) { isExempt(context) }
|
||||
val detected = remember { detectBrand() }
|
||||
var brand by rememberSaveable { mutableIntStateOf(detected.ordinal) }
|
||||
fun open(intent: Intent) = runCatching { context.startActivity(intent) }.isSuccess
|
||||
|
||||
SecondaryScaffold(title = stringResource(R.string.battery_title), onBack = onBack) { padding ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.battery_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp, fontWeight = FontWeight.Normal),
|
||||
color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
StepCard(1, stringResource(R.string.battery_step1_title), highlight = !exempt)
|
||||
{
|
||||
Text(stringResource(R.string.battery_step1_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||
if (exempt)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(c.success))
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text(stringResource(R.string.battery_step1_done), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.onSuccess)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxButton(
|
||||
stringResource(R.string.perm_allow),
|
||||
{ open(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.packageName))) },
|
||||
height = 48.dp, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
StepCard(2, stringResource(R.string.battery_step2_title), highlight = false)
|
||||
{
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Brand.entries.forEach { b -> ShxChip(stringResource(b.label), brand == b.ordinal, { brand = b.ordinal }) }
|
||||
}
|
||||
if (detected != Brand.OTHER)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.battery_detected, Build.MANUFACTURER.replaceFirstChar { it.uppercase() }),
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.accentText,
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
Brand.entries[brand].steps.forEachIndexed { i, step ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(c.tile))
|
||||
{
|
||||
Text("${i + 1}", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||
}
|
||||
Text(stringResource(step), style = ShxType.body.copy(fontWeight = FontWeight.Normal, lineHeight = 20.sp), color = c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxButton(
|
||||
stringResource(R.string.battery_open_app_settings),
|
||||
{ open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))) },
|
||||
style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp))
|
||||
.background(if (exempt) c.successSoft else c.warningContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(if (exempt) ShxIcons.Check else ShxIcons.Alert, contentDescription = null, tint = if (exempt) c.onSuccess else c.onWarningContainer, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
stringResource(if (exempt) R.string.battery_ok else R.string.battery_not_done),
|
||||
style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = if (exempt) c.onSuccess else c.onWarningContainer,
|
||||
)
|
||||
}
|
||||
ShxButton(stringResource(R.string.battery_check_again), { checks++ }, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepCard(n: Int, title: String, highlight: Boolean, content: @Composable () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, if (highlight) c.accentBorder else c.cardBorder, shape).padding(16.dp),
|
||||
)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
|
||||
{
|
||||
Text("$n", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||
}
|
||||
Text(title, style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -133,7 +133,9 @@ fun PermissionsList(modifier: Modifier = Modifier)
|
||||
}
|
||||
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))) }
|
||||
/* the vendor switches (step 2 of F_Battery) matter even when Android allows it */
|
||||
openWhenDone = true,
|
||||
) { open(Intent(context, org.strongswan.android.ui.BatteryActivity::class.java)) }
|
||||
PermissionCard(
|
||||
ShxIcons.Stats, stringResource(R.string.perm_usage), PermissionLevel.OPTIONAL, stringResource(R.string.perm_usage_why), status.usage,
|
||||
)
|
||||
@@ -150,7 +152,7 @@ fun PermissionsList(modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLevel, why: String, done: Boolean, onAllow: () -> Unit)
|
||||
private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLevel, why: String, done: Boolean, openWhenDone: Boolean = false, onAllow: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val (tagBg, tagFg, tag) = when (level)
|
||||
@@ -164,7 +166,8 @@ private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLe
|
||||
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),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, border, shape)
|
||||
.then(if (done && openWhenDone) Modifier.clickable(role = Role.Button, onClick = onAllow) else Modifier).padding(14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(if (done) c.successContainer else c.tile))
|
||||
|
||||
@@ -1095,4 +1095,30 @@
|
||||
<string name="cert_expired_text">«%1$s» не подключается с %2$s. Попросите у администратора новый профиль и добавьте его.</string>
|
||||
<string name="cert_update_now">Обновить сейчас</string>
|
||||
<string name="cert_later">Позже</string>
|
||||
<string name="battery_title">Без экономии батареи</string>
|
||||
<string name="battery_intro">VPN почти не тратит заряд, но Android этого не знает и усыпляет его в фоне. Два шага:</string>
|
||||
<string name="battery_step1_title">Системное разрешение</string>
|
||||
<string name="battery_step1_text">Появится окно «Разрешить работу в фоне?» — нажмите «Разрешить».</string>
|
||||
<string name="battery_step1_done">Разрешено</string>
|
||||
<string name="battery_step2_title">Настройка телефона</string>
|
||||
<string name="battery_detected">Определили: %1$s</string>
|
||||
<string name="battery_brand_xiaomi">Xiaomi</string>
|
||||
<string name="battery_brand_samsung">Samsung</string>
|
||||
<string name="battery_brand_huawei">Honor / Huawei</string>
|
||||
<string name="battery_brand_oppo">Realme / OPPO</string>
|
||||
<string name="battery_brand_other">Другой</string>
|
||||
<string name="battery_step_open">Откройте настройки приложения кнопкой ниже</string>
|
||||
<string name="battery_xiaomi_2">Экономия заряда → «Нет ограничений»</string>
|
||||
<string name="battery_xiaomi_3">Включите «Автозапуск»</string>
|
||||
<string name="battery_samsung_2">Батарея → «Без ограничений»</string>
|
||||
<string name="battery_samsung_3">Обслуживание устройства → Батарея: уберите SHX VPN из «Спящих приложений»</string>
|
||||
<string name="battery_huawei_2">Батарея → Запуск приложений: отключите «Автоматическое управление»</string>
|
||||
<string name="battery_huawei_3">Включите «Автозапуск», «Косвенный запуск» и «Работа в фоне»</string>
|
||||
<string name="battery_oppo_2">Использование батареи → «Разрешить работу в фоне»</string>
|
||||
<string name="battery_oppo_3">Включите «Автозапуск»</string>
|
||||
<string name="battery_other_2">Батарея → «Без ограничений» или «Не оптимизировать»</string>
|
||||
<string name="battery_open_app_settings">Открыть настройки приложения</string>
|
||||
<string name="battery_not_done">Шаг 1 ещё не сделан — VPN может отключаться в фоне</string>
|
||||
<string name="battery_ok">Готово: Android не будет усыплять VPN</string>
|
||||
<string name="battery_check_again">Проверить снова</string>
|
||||
</resources>
|
||||
@@ -1081,4 +1081,30 @@
|
||||
<string name="cert_expired_text">«%1$s» no longer connects since %2$s. Ask your admin for a new profile and add it.</string>
|
||||
<string name="cert_update_now">Update now</string>
|
||||
<string name="cert_later">Later</string>
|
||||
<string name="battery_title">Without battery saving</string>
|
||||
<string name="battery_intro">The VPN barely uses any charge, but Android does not know that and puts it to sleep in the background. Two steps:</string>
|
||||
<string name="battery_step1_title">System permission</string>
|
||||
<string name="battery_step1_text">A «Let app always run in background?» window appears — tap «Allow».</string>
|
||||
<string name="battery_step1_done">Allowed</string>
|
||||
<string name="battery_step2_title">Phone settings</string>
|
||||
<string name="battery_detected">Detected: %1$s</string>
|
||||
<string name="battery_brand_xiaomi">Xiaomi</string>
|
||||
<string name="battery_brand_samsung">Samsung</string>
|
||||
<string name="battery_brand_huawei">Honor / Huawei</string>
|
||||
<string name="battery_brand_oppo">Realme / OPPO</string>
|
||||
<string name="battery_brand_other">Other</string>
|
||||
<string name="battery_step_open">Open the app settings with the button below</string>
|
||||
<string name="battery_xiaomi_2">Battery saver → «No restrictions»</string>
|
||||
<string name="battery_xiaomi_3">Turn on «Autostart»</string>
|
||||
<string name="battery_samsung_2">Battery → «Unrestricted»</string>
|
||||
<string name="battery_samsung_3">Device care → Battery: remove SHX VPN from «Sleeping apps»</string>
|
||||
<string name="battery_huawei_2">Battery → App launch: turn off «Manage automatically»</string>
|
||||
<string name="battery_huawei_3">Turn on «Auto-launch», «Secondary launch» and «Run in background»</string>
|
||||
<string name="battery_oppo_2">Battery usage → «Allow background activity»</string>
|
||||
<string name="battery_oppo_3">Turn on «Auto launch»</string>
|
||||
<string name="battery_other_2">Battery → «Unrestricted» or «Not optimized»</string>
|
||||
<string name="battery_open_app_settings">Open app settings</string>
|
||||
<string name="battery_not_done">Step 1 is not done yet — the VPN may stop in the background</string>
|
||||
<string name="battery_ok">Done: Android will not put the VPN to sleep</string>
|
||||
<string name="battery_check_again">Check again</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user