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