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