diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index 9ae02a177..f0dfa5cfa 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -79,6 +79,9 @@
+
)
+{
+ 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()
+ }
+}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt
index e839d9ada..e525892ec 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PermissionsScreen.kt
@@ -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))
diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml
index d749dbd3f..46b646772 100644
--- a/src/frontends/android/app/src/main/res/values-ru/strings.xml
+++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml
@@ -1095,4 +1095,30 @@
«%1$s» не подключается с %2$s. Попросите у администратора новый профиль и добавьте его.
Обновить сейчас
Позже
+ Без экономии батареи
+ VPN почти не тратит заряд, но Android этого не знает и усыпляет его в фоне. Два шага:
+ Системное разрешение
+ Появится окно «Разрешить работу в фоне?» — нажмите «Разрешить».
+ Разрешено
+ Настройка телефона
+ Определили: %1$s
+ Xiaomi
+ Samsung
+ Honor / Huawei
+ Realme / OPPO
+ Другой
+ Откройте настройки приложения кнопкой ниже
+ Экономия заряда → «Нет ограничений»
+ Включите «Автозапуск»
+ Батарея → «Без ограничений»
+ Обслуживание устройства → Батарея: уберите SHX VPN из «Спящих приложений»
+ Батарея → Запуск приложений: отключите «Автоматическое управление»
+ Включите «Автозапуск», «Косвенный запуск» и «Работа в фоне»
+ Использование батареи → «Разрешить работу в фоне»
+ Включите «Автозапуск»
+ Батарея → «Без ограничений» или «Не оптимизировать»
+ Открыть настройки приложения
+ Шаг 1 ещё не сделан — VPN может отключаться в фоне
+ Готово: Android не будет усыплять VPN
+ Проверить снова
diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml
index f548a4863..cb10ab28f 100644
--- a/src/frontends/android/app/src/main/res/values/strings.xml
+++ b/src/frontends/android/app/src/main/res/values/strings.xml
@@ -1081,4 +1081,30 @@
«%1$s» no longer connects since %2$s. Ask your admin for a new profile and add it.
Update now
Later
+ Without battery saving
+ The VPN barely uses any charge, but Android does not know that and puts it to sleep in the background. Two steps:
+ System permission
+ A «Let app always run in background?» window appears — tap «Allow».
+ Allowed
+ Phone settings
+ Detected: %1$s
+ Xiaomi
+ Samsung
+ Honor / Huawei
+ Realme / OPPO
+ Other
+ Open the app settings with the button below
+ Battery saver → «No restrictions»
+ Turn on «Autostart»
+ Battery → «Unrestricted»
+ Device care → Battery: remove SHX VPN from «Sleeping apps»
+ Battery → App launch: turn off «Manage automatically»
+ Turn on «Auto-launch», «Secondary launch» and «Run in background»
+ Battery usage → «Allow background activity»
+ Turn on «Auto launch»
+ Battery → «Unrestricted» or «Not optimized»
+ Open app settings
+ Step 1 is not done yet — the VPN may stop in the background
+ Done: Android will not put the VPN to sleep
+ Check again