diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
index d958184..6b4c814 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
@@ -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()
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt
index 7f89fff..b6038d9 100644
--- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt
@@ -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()"
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt
index efe94dd..b7442c9 100644
--- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt
@@ -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 = {
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
index f34199c..2f41fea 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
@@ -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"
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
index ed37cb6..64f417f 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
@@ -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 }
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 14e6cc8..4989819 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -204,6 +204,9 @@
نمایش سرعت بلادرنگ در اعلان
به دلیل محدودیتهای اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دستهبندی اعلان را در تنظیمات غیرفعال کنید.
به دلیل محدودیتهای اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلانها را در اطلاعات برنامه غیرفعال کنید.
+ اجازه دور زدن VPN
+ در صورت فعال بودن، برنامهها میتوانند این اتصال VPN را دور بزنند و مستقیماً از شبکه اصلی استفاده کنند.
+ مستندات Android
تغییر مسیر خودکار
نیازمند دسترسی ROOT
پراکسی HTTP سیستم
diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml
index ae2aa0d..3e9fca6 100644
--- a/app/src/main/res/values-ru-rRU/strings.xml
+++ b/app/src/main/res/values-ru-rRU/strings.xml
@@ -204,6 +204,9 @@
Отображать скорость в реальном времени в уведомлении
Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках.
Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении.
+ Разрешить обход VPN
+ Если включено, приложения могут обойти это VPN-соединение и использовать базовую сеть напрямую.
+ Документация Android
Автоматическое перенаправление
Требуются права ROOT
Системный HTTP-прокси
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 451032f..7ca82f6 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -206,6 +206,9 @@
在通知中显示实时网速
由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。
由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。
+ 允许绕过 VPN
+ 启用后,应用可以绕过此 VPN 连接,直接使用底层网络。
+ Android 文档
自动重定向
需要 ROOT 权限
系统 HTTP 代理
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 70e5295..554892d 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -206,6 +206,9 @@
在通知中顯示即時網速
由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。
由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。
+ 允許繞過 VPN
+ 啟用後,應用程式可以繞過此 VPN 連線,直接使用底層網路。
+ Android 文件
自動重定向
需要 ROOT 權限
系統 HTTP 代理
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 63a3787..8d020cb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -206,6 +206,9 @@
Display realtime speed in notification
Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category.
Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications.
+ Allow Bypass
+ If enabled, applications can bypass this VPN connection and instead use the underlying network directly.
+ Android Documentation
Auto Redirect
ROOT permission required
System HTTP Proxy