Add Allow Bypass VPN setting to service screen

Add a toggle in the service settings screen that calls
VpnService.Builder.allowBypass() when enabled, with a description
linking to Android documentation. Always show the Service item
in the settings list (remove battery-optimization-gated visibility).
This commit is contained in:
世界
2026-03-16 18:38:00 +08:00
parent b3515329c2
commit 3692d54420
10 changed files with 145 additions and 42 deletions

View File

@@ -66,6 +66,10 @@ class VPNService :
builder.setMetered(false)
}
if (Settings.allowBypass) {
builder.allowBypass()
}
val inet4Address = options.inet4Address
while (inet4Address.hasNext()) {
val address = inet4Address.next()

View File

@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -26,8 +28,11 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -35,18 +40,28 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.launchCustomTab
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -66,14 +81,13 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
}
val context = LocalContext.current
// Check battery optimization status
val scope = rememberCoroutineScope()
var isBatteryOptimizationIgnored by remember { mutableStateOf(false) }
// Activity result launcher for battery optimization permission
var allowBypass by remember { mutableStateOf(Settings.allowBypass) }
val requestBatteryOptimizationLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { _ ->
// Recheck the status after returning from settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java)
isBatteryOptimizationIgnored =
@@ -81,7 +95,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
}
}
// Check battery optimization status on launch
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java)
@@ -100,7 +113,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// Background Permission Card (only show if battery optimization is not ignored)
if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card(
modifier =
@@ -171,6 +183,93 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
}
}
// VPN Section
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "VPN",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
val descriptionText = stringResource(R.string.allow_bypass_description)
val linkText = stringResource(R.string.android_documentation)
val linkColor = MaterialTheme.colorScheme.primary
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val textStyle = MaterialTheme.typography.bodyMedium
ListItem(
headlineContent = {
Text(
stringResource(R.string.allow_bypass),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = textColor)) {
append(descriptionText)
}
append("\n\n")
pushStringAnnotation(tag = "URL", annotation = ALLOW_BYPASS_DOC_URL)
withStyle(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
append(linkText)
}
pop()
}
ClickableText(
text = annotatedString,
style = textStyle,
modifier = Modifier.padding(top = 4.dp),
onClick = { offset ->
annotatedString.getStringAnnotations(
tag = "URL",
start = offset,
end = offset,
).firstOrNull()?.let {
context.launchCustomTab(it.item)
}
},
)
},
trailingContent = {
Switch(
checked = allowBypass,
onCheckedChange = { checked ->
allowBypass = checked
scope.launch(Dispatchers.IO) {
Settings.allowBypass = checked
}
},
)
},
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
private const val ALLOW_BYPASS_DOC_URL =
"https://developer.android.com/reference/android/net/VpnService.Builder#allowBypass()"

View File

@@ -1,7 +1,5 @@
package io.nekohasekai.sfa.compose.screen.settings
import android.os.Build
import android.os.PowerManager
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -37,10 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -70,15 +65,8 @@ fun SettingsScreen(navController: NavController) {
val hookStatus by HookStatusClient.status.collectAsState()
val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus)
val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus)
var isBatteryOptimizationIgnored by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
HookStatusClient.refresh()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java)
isBatteryOptimizationIgnored =
pm?.isIgnoringBatteryOptimizations(context.packageName) == true
}
}
Column(
@@ -153,31 +141,26 @@ fun SettingsScreen(navController: NavController) {
),
)
if (!isBatteryOptimizationIgnored) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.service),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
Badge(containerColor = MaterialTheme.colorScheme.primary)
},
modifier = Modifier.clickable { navController.navigate("settings/service") },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
ListItem(
headlineContent = {
Text(
stringResource(R.string.service),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier.clickable { navController.navigate("settings/service") },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
ListItem(
headlineContent = {

View File

@@ -23,6 +23,7 @@ object SettingsKey {
const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list"
const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode"
const val ALLOW_BYPASS = "allow_bypass"
const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled"
const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled"

View File

@@ -96,6 +96,7 @@ object Settings {
perAppProxyList
}
var allowBypass by dataStore.boolean(SettingsKey.ALLOW_BYPASS) { false }
var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true }
var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false }

View File

@@ -204,6 +204,9 @@
<string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string>
<string name="disable_notification_description">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید.</string>
<string name="disable_notification_description_legacy">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید.</string>
<string name="allow_bypass">اجازه دور زدن VPN</string>
<string name="allow_bypass_description">در صورت فعال بودن، برنامه‌ها می‌توانند این اتصال VPN را دور بزنند و مستقیماً از شبکه اصلی استفاده کنند.</string>
<string name="android_documentation">مستندات Android</string>
<string name="auto_redirect">تغییر مسیر خودکار</string>
<string name="auto_redirect_description">نیازمند دسترسی ROOT</string>
<string name="system_http_proxy">پراکسی HTTP سیستم</string>

View File

@@ -204,6 +204,9 @@
<string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string>
<string name="disable_notification_description">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках.</string>
<string name="disable_notification_description_legacy">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении.</string>
<string name="allow_bypass">Разрешить обход VPN</string>
<string name="allow_bypass_description">Если включено, приложения могут обойти это VPN-соединение и использовать базовую сеть напрямую.</string>
<string name="android_documentation">Документация Android</string>
<string name="auto_redirect">Автоматическое перенаправление</string>
<string name="auto_redirect_description">Требуются права ROOT</string>
<string name="system_http_proxy">Системный HTTP-прокси</string>

View File

@@ -206,6 +206,9 @@
<string name="dynamic_notification">在通知中显示实时网速</string>
<string name="disable_notification_description">由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。</string>
<string name="disable_notification_description_legacy">由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。</string>
<string name="allow_bypass">允许绕过 VPN</string>
<string name="allow_bypass_description">启用后,应用可以绕过此 VPN 连接,直接使用底层网络。</string>
<string name="android_documentation">Android 文档</string>
<string name="auto_redirect">自动重定向</string>
<string name="auto_redirect_description">需要 ROOT 权限</string>
<string name="system_http_proxy">系统 HTTP 代理</string>

View File

@@ -206,6 +206,9 @@
<string name="dynamic_notification">在通知中顯示即時網速</string>
<string name="disable_notification_description">由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。</string>
<string name="disable_notification_description_legacy">由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。</string>
<string name="allow_bypass">允許繞過 VPN</string>
<string name="allow_bypass_description">啟用後,應用程式可以繞過此 VPN 連線,直接使用底層網路。</string>
<string name="android_documentation">Android 文件</string>
<string name="auto_redirect">自動重定向</string>
<string name="auto_redirect_description">需要 ROOT 權限</string>
<string name="system_http_proxy">系統 HTTP 代理</string>

View File

@@ -206,6 +206,9 @@
<string name="dynamic_notification">Display realtime speed in notification</string>
<string name="disable_notification_description">Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category.</string>
<string name="disable_notification_description_legacy">Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications.</string>
<string name="allow_bypass">Allow Bypass</string>
<string name="allow_bypass_description">If enabled, applications can bypass this VPN connection and instead use the underlying network directly.</string>
<string name="android_documentation">Android Documentation</string>
<string name="auto_redirect">Auto Redirect</string>
<string name="auto_redirect_description">ROOT permission required</string>
<string name="system_http_proxy">System HTTP Proxy</string>