diff --git a/ui/settings/src/main/kotlin/com/github/kr328/clash/settings/ui/AppSettingsScreen.kt b/ui/settings/src/main/kotlin/com/github/kr328/clash/settings/ui/AppSettingsScreen.kt index 463b364afe..cb6a5799fd 100644 --- a/ui/settings/src/main/kotlin/com/github/kr328/clash/settings/ui/AppSettingsScreen.kt +++ b/ui/settings/src/main/kotlin/com/github/kr328/clash/settings/ui/AppSettingsScreen.kt @@ -1,26 +1,44 @@ package com.github.kr328.clash.settings.ui +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.net.VpnService +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.StringRes import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn 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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.github.kr328.clash.glue.model.DarkMode import com.github.kr328.clash.settings.R import com.github.kr328.clash.settings.vm.AppSettingsViewModel import com.github.kr328.clash.ui.component.TabbyScaffold +import com.github.kr328.clash.ui.icon.BaselineBatterySaver import com.github.kr328.clash.ui.icon.BaselineBrightness4 import com.github.kr328.clash.ui.icon.BaselineDomain import com.github.kr328.clash.ui.icon.BaselineHide import com.github.kr328.clash.ui.icon.BaselineRestore import com.github.kr328.clash.ui.icon.BaselineStack +import com.github.kr328.clash.ui.icon.BaselineVpnLock import com.github.kr328.clash.ui.icon.TabbyIcons import com.github.kr328.clash.ui.theme.PreviewTabby import com.github.kr328.clash.ui.theme.TabbyThemeWrapper @@ -34,17 +52,63 @@ internal fun AppSettingsScreen( modifier: Modifier = Modifier, viewModel: AppSettingsViewModel = viewModel(), ) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val clashRunning by viewModel.clashRunning.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var vpnPermissionGranted by remember(context) { mutableStateOf(isVpnPermissionGranted(context)) } + var batteryOptimizationIgnored by + remember(context) { mutableStateOf(isBatteryOptimizationIgnored(context)) } + + val vpnPermissionLauncher = + rememberLauncherForActivityResult(StartActivityForResult()) { + vpnPermissionGranted = isVpnPermissionGranted(context) + } + val batteryOptimizationLauncher = + rememberLauncherForActivityResult(StartActivityForResult()) { + batteryOptimizationIgnored = isBatteryOptimizationIgnored(context) + } + + DisposableEffect(context, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + vpnPermissionGranted = isVpnPermissionGranted(context) + batteryOptimizationIgnored = isBatteryOptimizationIgnored(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } AppSettingsContent( clashRunning = clashRunning, uiState = uiState, + vpnPermissionGranted = vpnPermissionGranted, + batteryOptimizationIgnored = batteryOptimizationIgnored, onAutoRestartChange = viewModel::updateAutoRestart, onDarkModeChange = viewModel::updateDarkMode, onHideAppIconChange = viewModel::updateHideAppIcon, onHideFromRecentsChange = viewModel::updateHideFromRecents, onDynamicNotificationChange = viewModel::updateDynamicNotification, + onAlwaysOnVpnChange = { enabled -> + if (!enabled) return@AppSettingsContent + + val permissionIntent = VpnService.prepare(context) + if (permissionIntent != null) { + vpnPermissionLauncher.launch(permissionIntent) + } else { + context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) + } + }, + onIgnoreBatteryOptimizationChange = { enabled -> + if (!enabled) return@AppSettingsContent + + batteryOptimizationLauncher.launch( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + .setData(Uri.parse("package:${context.packageName}")) + ) + }, modifier = modifier, ) } @@ -53,11 +117,15 @@ internal fun AppSettingsScreen( private fun AppSettingsContent( clashRunning: Boolean, uiState: AppSettingsViewModel.UiState, + vpnPermissionGranted: Boolean, + batteryOptimizationIgnored: Boolean, onAutoRestartChange: (Boolean) -> Unit, onDarkModeChange: (DarkMode) -> Unit, onHideAppIconChange: (Boolean) -> Unit, onHideFromRecentsChange: (Boolean) -> Unit, onDynamicNotificationChange: (Boolean) -> Unit, + onAlwaysOnVpnChange: (Boolean) -> Unit, + onIgnoreBatteryOptimizationChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { TabbyScaffold(title = stringResource(R.string.app), modifier = modifier) { innerPadding -> @@ -115,6 +183,24 @@ private fun AppSettingsContent( title = { Text(stringResource(R.string.show_traffic)) }, summary = { Text(stringResource(R.string.show_traffic_summary)) }, ) + switchPreference( + key = "always_on_vpn", + value = vpnPermissionGranted, + onValueChange = onAlwaysOnVpnChange, + enabled = !vpnPermissionGranted, + icon = { Icon(imageVector = TabbyIcons.BaselineVpnLock, contentDescription = null) }, + title = { Text(stringResource(R.string.always_on_vpn)) }, + summary = { Text(stringResource(R.string.always_on_vpn_summary)) }, + ) + switchPreference( + key = "ignore_battery_optimizations", + value = batteryOptimizationIgnored, + onValueChange = onIgnoreBatteryOptimizationChange, + enabled = !batteryOptimizationIgnored, + icon = { Icon(imageVector = TabbyIcons.BaselineBatterySaver, contentDescription = null) }, + title = { Text(stringResource(R.string.ignore_battery_optimizations)) }, + summary = { Text(stringResource(R.string.ignore_battery_optimizations_summary)) }, + ) } } } @@ -143,11 +229,15 @@ private fun AppSettingsScreenPreview() { hideFromRecents = false, dynamicNotification = true, ), + vpnPermissionGranted = false, + batteryOptimizationIgnored = false, onAutoRestartChange = {}, onDarkModeChange = {}, onHideAppIconChange = {}, onHideFromRecentsChange = {}, onDynamicNotificationChange = {}, + onAlwaysOnVpnChange = {}, + onIgnoreBatteryOptimizationChange = {}, ) } @@ -165,10 +255,24 @@ private fun AppSettingsScreenRunningPreview() { hideFromRecents = true, dynamicNotification = true, ), + vpnPermissionGranted = true, + batteryOptimizationIgnored = true, onAutoRestartChange = {}, onDarkModeChange = {}, onHideAppIconChange = {}, onHideFromRecentsChange = {}, onDynamicNotificationChange = {}, + onAlwaysOnVpnChange = {}, + onIgnoreBatteryOptimizationChange = {}, ) } + +private fun isVpnPermissionGranted(context: Context): Boolean { + return VpnService.prepare(context) == null +} + +private fun isBatteryOptimizationIgnored(context: Context): Boolean { + return context + .getSystemService(PowerManager::class.java) + ?.isIgnoringBatteryOptimizations(context.packageName) == true +} diff --git a/ui/settings/src/main/res/values-ja-rJP/strings.xml b/ui/settings/src/main/res/values-ja-rJP/strings.xml index 3ba8e5df88..d272419aaf 100644 --- a/ui/settings/src/main/res/values-ja-rJP/strings.xml +++ b/ui/settings/src/main/res/values-ja-rJP/strings.xml @@ -16,6 +16,8 @@ Always 常にダークモード 常にライトモード + 常時 VPN 接続 + VPN 権限をリクエストするか VPN 設定を開きます アプリ システムDNSを追加 認証 @@ -80,6 +82,8 @@ 情報 インストール日時 インターフェイス + 電池の最適化を無視 + 電池の最適化の対象外にリクエストします IPCIDRフォールバック IPv6 キー diff --git a/ui/settings/src/main/res/values-ko-rKR/strings.xml b/ui/settings/src/main/res/values-ko-rKR/strings.xml index 5f139190d1..1f497949c7 100644 --- a/ui/settings/src/main/res/values-ko-rKR/strings.xml +++ b/ui/settings/src/main/res/values-ko-rKR/strings.xml @@ -16,6 +16,8 @@ 강제로 켜기 다크 라이트 + 항상 켜져 있는 VPN + VPN 권한 요청 또는 VPN 설정 열기 시스템 DNS 허용 인증 @@ -80,6 +82,8 @@ 정보 설치 시각 테마 + 배터리 최적화 무시 + 배터리 최적화에서 제외 요청 IPCIDR 폴백 IPv6 diff --git a/ui/settings/src/main/res/values-ru/strings.xml b/ui/settings/src/main/res/values-ru/strings.xml index f8c155101e..6411be90f5 100644 --- a/ui/settings/src/main/res/values-ru/strings.xml +++ b/ui/settings/src/main/res/values-ru/strings.xml @@ -16,6 +16,8 @@ Принудительно включить Всегда тёмная Всегда светлая + Постоянное VPN-соединение + Запросить разрешение VPN или открыть настройки VPN Приложение Добавить системный DNS Аутентификация @@ -80,6 +82,8 @@ Инфо Время установки Интерфейс + Игнорировать оптимизацию батареи + Запросить исключение из оптимизации батареи Резервный IPCIDR IPv6 Ключ diff --git a/ui/settings/src/main/res/values-vi/strings.xml b/ui/settings/src/main/res/values-vi/strings.xml index 6189e428bf..a7a2b2e207 100644 --- a/ui/settings/src/main/res/values-vi/strings.xml +++ b/ui/settings/src/main/res/values-vi/strings.xml @@ -16,6 +16,8 @@ Luôn luôn Luôn tối Luôn sáng + VPN luôn bật + Yêu cầu quyền VPN hoặc mở cài đặt VPN Ứng dụng Nối hệ thống DNS Xác thực @@ -80,6 +82,8 @@ Thông tin Thời gian cài đặt Giao diện + Bỏ qua tối ưu hóa pin + Yêu cầu loại trừ khỏi tối ưu hóa pin Dự phòng IPCIDR IPv6 Khoá diff --git a/ui/settings/src/main/res/values-zh-rHK/strings.xml b/ui/settings/src/main/res/values-zh-rHK/strings.xml index 1045f61abe..4200cf1ceb 100644 --- a/ui/settings/src/main/res/values-zh-rHK/strings.xml +++ b/ui/settings/src/main/res/values-zh-rHK/strings.xml @@ -16,6 +16,8 @@ 強制開啟 總是暗黑模式 總是明亮模式 + 始終開啟 VPN + 請求 VPN 權限或打開 VPN 設置 應用 追加系統 DNS 認證 @@ -80,6 +82,8 @@ 消息 安裝時間 界面 + 忽略電池優化 + 請求將此應用排除在電池優化之外 IPCIDR Fallback IPv6 diff --git a/ui/settings/src/main/res/values-zh-rTW/strings.xml b/ui/settings/src/main/res/values-zh-rTW/strings.xml index f4fde0536b..f241059979 100644 --- a/ui/settings/src/main/res/values-zh-rTW/strings.xml +++ b/ui/settings/src/main/res/values-zh-rTW/strings.xml @@ -16,6 +16,8 @@ 強制開啟 深色 淺色 + 永遠開啟 VPN + 請求 VPN 權限或開啟 VPN 設定 應用 附加作業系統 DNS 認證 @@ -80,6 +82,8 @@ 資訊 安裝時間 介面 + 忽略電池最佳化 + 請求將此應用程式排除於電池最佳化之外 IPCIDR 後饋 IPv6 diff --git a/ui/settings/src/main/res/values-zh/strings.xml b/ui/settings/src/main/res/values-zh/strings.xml index f1f37ff23a..143ab613d1 100644 --- a/ui/settings/src/main/res/values-zh/strings.xml +++ b/ui/settings/src/main/res/values-zh/strings.xml @@ -16,6 +16,8 @@ 强制开启 总是暗黑模式 总是明亮模式 + 始终开启 VPN + 请求 VPN 权限或打开 VPN 设置 应用 追加系统 DNS 认证 @@ -80,6 +82,8 @@ 消息 安装时间 界面 + 忽略电池优化 + 请求将此应用排除在电池优化之外 IPCIDR Fallback IPv6 diff --git a/ui/settings/src/main/res/values/strings.xml b/ui/settings/src/main/res/values/strings.xml index ba1c28deab..0cb71ac6da 100644 --- a/ui/settings/src/main/res/values/strings.xml +++ b/ui/settings/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Always Always Dark Always Light + Always-on VPN + Request VPN permission or open VPN settings App Append System DNS Authentication @@ -78,6 +80,8 @@ Import GeoIP Database Import GeoSite Database Info + Ignore Battery Optimizations + Request battery optimization exclusion Install Time Interface IPCIDR Fallback diff --git a/ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineBatterySaver.kt b/ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineBatterySaver.kt new file mode 100644 index 0000000000..eb0c065c92 --- /dev/null +++ b/ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineBatterySaver.kt @@ -0,0 +1,60 @@ +package com.github.kr328.clash.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +@Suppress("UnusedReceiverParameter") +val TabbyIcons.BaselineBatterySaver: ImageVector + get() { + if (_BaselineBatterySaver != null) { + return _BaselineBatterySaver!! + } + _BaselineBatterySaver = + ImageVector.Builder( + name = "BaselineBatterySaver", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path(fill = SolidColor(Color.White)) { + moveTo(17f, 4f) + horizontalLineToRelative(-3f) + verticalLineTo(2f) + horizontalLineToRelative(-4f) + verticalLineToRelative(2f) + horizontalLineTo(7f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(15f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(10f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(6f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(16f, 15f) + horizontalLineToRelative(-2f) + verticalLineToRelative(2f) + horizontalLineToRelative(-2f) + verticalLineToRelative(-2f) + horizontalLineToRelative(-2f) + verticalLineToRelative(-2f) + horizontalLineToRelative(2f) + verticalLineToRelative(-2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + close() + } + } + .build() + + return _BaselineBatterySaver!! + } + +@Suppress("ObjectPropertyName") private var _BaselineBatterySaver: ImageVector? = null