android: battery saving instructions screen as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 11:07:20 +07:00
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()
}
}
@@ -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>