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