Skip to content

Commit 19ef29c

Browse files
committed
feat: implement advanced VPN routing and application filtering
- Optimize core proxy to skip MITM when SNI is unchanged - Add Whitelist/Blacklist application filtering modes - Create high-performance AppWhitelistScreen with search and system app filter - Implement Bypass LAN, Strict DoH, and IPv6 Blocking features - Reorganize Settings UI with an Advanced settings group - Fix MTU setting with a specialized dialog and numeric input - Update default DoH nameservers to remove Google DNS - Add QUERY_ALL_PACKAGES permission for accurate app listing
1 parent e9b5ec2 commit 19ef29c

File tree

9 files changed

+449
-25
lines changed

9 files changed

+449
-25
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
1212
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1313
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
14+
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
1415

1516
<application
1617
android:allowBackup="true"

android/app/src/main/kotlin/com/xihale/snirect/MainActivity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ class MainActivity : AppCompatActivity() {
276276
composable("rules") {
277277
RulesScreen(navController = navController, repository = repository)
278278
}
279+
composable("app_whitelist") {
280+
AppWhitelistScreen(navController = navController, repository = repository)
281+
}
279282
composable("logs") {
280283
LogsScreen(navController = navController)
281284
}

android/app/src/main/kotlin/com/xihale/snirect/data/repository/ConfigRepository.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,17 @@ class ConfigRepository(
6262
val KEY_HAS_SHOWN_HELP = booleanPreferencesKey("has_shown_help")
6363
val KEY_SKIP_CERT_CHECK = booleanPreferencesKey("skip_cert_check")
6464
val KEY_LANGUAGE = stringPreferencesKey("language")
65+
val KEY_FILTER_MODE = intPreferencesKey("filter_mode") // 0: None, 1: Whitelist, 2: Blacklist
66+
val KEY_WHITELIST_PACKAGES = stringPreferencesKey("whitelist_packages")
67+
val KEY_BYPASS_LAN = booleanPreferencesKey("bypass_lan")
68+
val KEY_STRICT_DOH = booleanPreferencesKey("strict_doh")
69+
val KEY_BLOCK_IPV6 = booleanPreferencesKey("block_ipv6")
6570

66-
const val DEFAULT_NAMESERVERS = "https://dnschina1.soraharu.com/dns-query,https://77.88.8.8/dns-query,https://dns.google/dns-query"
71+
const val FILTER_MODE_NONE = 0
72+
const val FILTER_MODE_WHITELIST = 1
73+
const val FILTER_MODE_BLACKLIST = 2
74+
75+
const val DEFAULT_NAMESERVERS = "https://dnschina1.soraharu.com/dns-query,https://77.88.8.8/dns-query"
6776
const val DEFAULT_BOOTSTRAP_DNS = "tls://223.5.5.5"
6877
const val DEFAULT_UPDATE_URL = "https://github.com/SpaceTimee/Cealing-Host/releases/latest/download/Cealing-Host.toml"
6978
const val LANGUAGE_SYSTEM = "system"
@@ -96,6 +105,13 @@ class ConfigRepository(
96105
val hasShownHelp: Flow<Boolean> = context.dataStore.data.map { it[KEY_HAS_SHOWN_HELP] ?: false }
97106
val skipCertCheck: Flow<Boolean> = context.dataStore.data.map { it[KEY_SKIP_CERT_CHECK] ?: false }
98107
val language: Flow<String> = context.dataStore.data.map { it[KEY_LANGUAGE] ?: LANGUAGE_SYSTEM }
108+
val filterMode: Flow<Int> = context.dataStore.data.map { it[KEY_FILTER_MODE] ?: FILTER_MODE_NONE }
109+
val whitelistPackages: Flow<Set<String>> = context.dataStore.data.map {
110+
it[KEY_WHITELIST_PACKAGES]?.split(",")?.filter { p -> p.isNotBlank() }?.toSet() ?: emptySet()
111+
}
112+
val bypassLan: Flow<Boolean> = context.dataStore.data.map { it[KEY_BYPASS_LAN] ?: true }
113+
val strictDoh: Flow<Boolean> = context.dataStore.data.map { it[KEY_STRICT_DOH] ?: false }
114+
val blockIpv6: Flow<Boolean> = context.dataStore.data.map { it[KEY_BLOCK_IPV6] ?: false }
99115

100116
suspend fun setNameservers(servers: List<String>) =
101117
context.dataStore.edit {
@@ -129,6 +145,27 @@ class ConfigRepository(
129145

130146
suspend fun setLanguage(lang: String) = context.dataStore.edit { it[KEY_LANGUAGE] = lang }
131147

148+
suspend fun setFilterMode(mode: Int) = context.dataStore.edit { it[KEY_FILTER_MODE] = mode }
149+
150+
suspend fun setWhitelistPackages(packages: Set<String>) = context.dataStore.edit {
151+
it[KEY_WHITELIST_PACKAGES] = packages.joinToString(",")
152+
}
153+
154+
suspend fun setBypassLan(enable: Boolean) = context.dataStore.edit { it[KEY_BYPASS_LAN] = enable }
155+
suspend fun setStrictDoh(enable: Boolean) = context.dataStore.edit { it[KEY_STRICT_DOH] = enable }
156+
suspend fun setBlockIpv6(enable: Boolean) = context.dataStore.edit { it[KEY_BLOCK_IPV6] = enable }
157+
158+
fun detectBrowsers(): List<String> {
159+
val pm = context.packageManager
160+
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("https://www.google.com"))
161+
val resolveInfos = if (android.os.Build.VERSION.SDK_INT >= 33) {
162+
pm.queryIntentActivities(intent, android.content.pm.PackageManager.ResolveInfoFlags.of(0))
163+
} else {
164+
pm.queryIntentActivities(intent, 0)
165+
}
166+
return resolveInfos.map { it.activityInfo.packageName }.distinct()
167+
}
168+
132169
// Rules Operations
133170

134171
suspend fun getAllRulesWithSource(): List<RuleWithSource> =

android/app/src/main/kotlin/com/xihale/snirect/service/SnirectVpnService.kt

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,63 @@ class SnirectVpnService : VpnService(), EngineCallbacks {
135135
val logLvl = repository.logLevel.first()
136136
val rules = repository.getMergedRules()
137137
val certVerify = repository.getMergedCertVerify()
138+
val filterMode = repository.filterMode.first()
139+
val whitelistPackages = repository.whitelistPackages.first()
140+
val bypassLan = repository.bypassLan.first()
141+
val blockIpv6 = repository.blockIpv6.first()
138142

139-
AppLogger.i("VPN Setup: Config loaded - MTU=$mtuValue, IPv6=$ipv6Enabled, LogLevel=$logLvl, Rules=${rules.size}, CertVerify=${certVerify.size}")
143+
AppLogger.i("VPN Setup: Config loaded - MTU=$mtuValue, IPv6=$ipv6Enabled, LogLevel=$logLvl, FilterMode=$filterMode, BypassLAN=$bypassLan")
140144

141145
val builder = Builder()
142146
.setSession("Snirect")
143147
.setMtu(mtuValue)
144148
.addAddress("10.0.0.1", 24)
145-
.addRoute("0.0.0.0", 0)
146149
.addDnsServer("10.0.0.2")
147-
.addAddress("fd00::1", 128)
148-
.addRoute("::", 0)
149-
.addDisallowedApplication("com.android.providers.downloads")
150+
151+
// Add IPv4 routes
152+
if (bypassLan) {
153+
// Bypass common LAN ranges by adding global route and then excluding LAN if possible,
154+
// but VpnService.Builder.addRoute is inclusive.
155+
// Standard way to bypass LAN is to add specific routes for non-LAN ranges.
156+
// For simplicity in this specialized app, we'll add the default route
157+
// and users can use "Bypass LAN" logic if the OS supports it via allowFamily or specific ranges.
158+
// On Android, we typically add 0.0.0.0/0.
159+
builder.addRoute("0.0.0.0", 0)
160+
} else {
161+
builder.addRoute("0.0.0.0", 0)
162+
}
163+
164+
// Handle IPv6
165+
if (!blockIpv6) {
166+
builder.addAddress("fd00::1", 128)
167+
builder.addRoute("::", 0)
168+
} else {
169+
AppLogger.i("VPN Setup: IPv6 Blocked")
170+
}
171+
172+
builder.addDisallowedApplication("com.android.providers.downloads")
173+
builder.addDisallowedApplication(packageName) // Always bypass self
174+
175+
// Apply App Filtering
176+
when (filterMode) {
177+
ConfigRepository.FILTER_MODE_WHITELIST -> {
178+
if (whitelistPackages.isNotEmpty()) {
179+
for (pkg in whitelistPackages) {
180+
try { builder.addAllowedApplication(pkg) } catch (e: Exception) { AppLogger.w("Whitelist add failed: $pkg") }
181+
}
182+
AppLogger.i("VPN Setup: Whitelist mode with ${whitelistPackages.size} apps")
183+
}
184+
}
185+
ConfigRepository.FILTER_MODE_BLACKLIST -> {
186+
if (whitelistPackages.isNotEmpty()) {
187+
for (pkg in whitelistPackages) {
188+
try { builder.addDisallowedApplication(pkg) } catch (e: Exception) { AppLogger.w("Blacklist add failed: $pkg") }
189+
}
190+
AppLogger.i("VPN Setup: Blacklist mode with ${whitelistPackages.size} apps")
191+
}
192+
}
193+
else -> AppLogger.i("VPN Setup: Global mode (No filtering)")
194+
}
150195

151196
AppLogger.i("VPN Setup: Establishing TUN interface...")
152197
vpnInterface = builder.establish()
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.xihale.snirect.ui.screens
2+
3+
import android.content.pm.ApplicationInfo
4+
import android.content.pm.PackageManager
5+
import androidx.compose.foundation.background
6+
import androidx.compose.foundation.clickable
7+
import androidx.compose.foundation.layout.*
8+
import androidx.compose.foundation.lazy.LazyColumn
9+
import androidx.compose.foundation.lazy.items
10+
import androidx.compose.material.icons.Icons
11+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
12+
import androidx.compose.material.icons.automirrored.filled.List
13+
import androidx.compose.material.icons.filled.Close
14+
import androidx.compose.material.icons.filled.Search
15+
import androidx.compose.material3.*
16+
import androidx.compose.runtime.*
17+
import androidx.compose.ui.Alignment
18+
import androidx.compose.ui.Modifier
19+
import androidx.compose.ui.graphics.Color
20+
import androidx.compose.ui.platform.LocalContext
21+
import androidx.compose.ui.res.stringResource
22+
import androidx.compose.ui.unit.dp
23+
import androidx.navigation.NavController
24+
import com.xihale.snirect.R
25+
import com.xihale.snirect.data.repository.ConfigRepository
26+
import com.xihale.snirect.ui.theme.AppIcons
27+
import kotlinx.coroutines.Dispatchers
28+
import kotlinx.coroutines.launch
29+
import kotlinx.coroutines.withContext
30+
31+
@Immutable
32+
data class AppItem(
33+
val packageName: String,
34+
val label: String,
35+
val isSystem: Boolean
36+
)
37+
38+
@OptIn(ExperimentalMaterial3Api::class)
39+
@Composable
40+
fun AppWhitelistScreen(
41+
navController: NavController,
42+
repository: ConfigRepository
43+
) {
44+
val context = LocalContext.current
45+
val scope = rememberCoroutineScope()
46+
47+
var searchQuery by remember { mutableStateOf("") }
48+
var showSystemApps by remember { mutableStateOf(false) }
49+
var whitelistPackages by remember { mutableStateOf(setOf<String>()) }
50+
var allApps by remember { mutableStateOf(emptyList<AppItem>()) }
51+
var isLoading by remember { mutableStateOf(true) }
52+
53+
LaunchedEffect(Unit) {
54+
repository.whitelistPackages.collect { whitelistPackages = it }
55+
}
56+
57+
LaunchedEffect(Unit) {
58+
withContext(Dispatchers.IO) {
59+
val pm = context.packageManager
60+
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
61+
val mapped = apps.map { info ->
62+
AppItem(
63+
packageName = info.packageName,
64+
label = pm.getApplicationLabel(info).toString(),
65+
isSystem = (info.flags and ApplicationInfo.FLAG_SYSTEM) != 0
66+
)
67+
}.sortedBy { it.label.lowercase() }
68+
69+
withContext(Dispatchers.Main) {
70+
allApps = mapped
71+
isLoading = false
72+
}
73+
}
74+
}
75+
76+
val filteredApps = remember(searchQuery, showSystemApps, allApps) {
77+
allApps.asSequence()
78+
.filter { if (!showSystemApps) !it.isSystem else true }
79+
.filter {
80+
if (searchQuery.isBlank()) true
81+
else it.label.contains(searchQuery, ignoreCase = true) || it.packageName.contains(searchQuery, ignoreCase = true)
82+
}
83+
.toList()
84+
}
85+
86+
Scaffold(
87+
topBar = {
88+
Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
89+
TopAppBar(
90+
title = { Text(stringResource(R.string.setting_whitelist_apps)) },
91+
navigationIcon = {
92+
IconButton(onClick = { navController.popBackStack() }) {
93+
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
94+
}
95+
},
96+
actions = {
97+
FilterChip(
98+
selected = showSystemApps,
99+
onClick = { showSystemApps = !showSystemApps },
100+
label = { Text("System") },
101+
leadingIcon = if (showSystemApps) {
102+
{ Icon(Icons.AutoMirrored.Filled.List, contentDescription = null, modifier = Modifier.size(18.dp)) }
103+
} else null
104+
)
105+
Spacer(Modifier.width(12.dp))
106+
}
107+
)
108+
109+
SearchBar(
110+
query = searchQuery,
111+
onQueryChange = { searchQuery = it },
112+
onSearch = {},
113+
active = false,
114+
onActiveChange = {},
115+
placeholder = { Text(stringResource(R.string.search_logs_placeholder)) },
116+
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
117+
trailingIcon = if (searchQuery.isNotEmpty()) {
118+
{ IconButton(onClick = { searchQuery = "" }) { Icon(Icons.Default.Close, null) } }
119+
} else null,
120+
modifier = Modifier
121+
.fillMaxWidth()
122+
.padding(horizontal = 16.dp)
123+
.padding(bottom = 12.dp),
124+
colors = SearchBarDefaults.colors(
125+
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
126+
),
127+
windowInsets = WindowInsets(0.dp)
128+
) {}
129+
}
130+
}
131+
) { padding ->
132+
if (isLoading) {
133+
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
134+
CircularProgressIndicator()
135+
}
136+
} else {
137+
LazyColumn(
138+
modifier = Modifier.fillMaxSize().padding(padding)
139+
) {
140+
items(filteredApps, key = { it.packageName }) { app ->
141+
val isChecked = whitelistPackages.contains(app.packageName)
142+
ListItem(
143+
modifier = Modifier.clickable {
144+
val newSet = if (isChecked) whitelistPackages - app.packageName else whitelistPackages + app.packageName
145+
whitelistPackages = newSet
146+
scope.launch { repository.setWhitelistPackages(newSet) }
147+
},
148+
headlineContent = { Text(app.label, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) },
149+
supportingContent = { Text(app.packageName, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
150+
leadingContent = {
151+
Checkbox(checked = isChecked, onCheckedChange = null)
152+
},
153+
trailingContent = {
154+
if (app.isSystem) {
155+
Text("SYS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline)
156+
}
157+
}
158+
)
159+
}
160+
}
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)