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 19:35:14 +08:00
parent b3515329c2
commit 3692d54420
10 changed files with 145 additions and 42 deletions
@@ -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()
@@ -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()"
@@ -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 = {
@@ -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"
@@ -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 }